I wanted to jot down some notes from my experience compiling a command-line application written in Elixir to a statically-linked binary on macOS. So far the experience has been great! I remember trying to do the same thing with OCaml a few years ago and it being a nightmare. Here are some gotchas I’ve run into, coming from a C++/TypeScript/Java background:

Building a statically-linked binary

It looks like the way to do this in 2024 is Burrito. Here’s lib/application.ex:

defmodule Foo.Application do
  def start(_, _) do
    IO.puts("hello world!")
    System.halt(0)
  end
end

and mix.exs:

defmodule Foo.MixProject do
  use Mix.Project
  def project do [
    app: :foo,
    version: "0.1.0",
    elixir: "~> 1.16",
    start_permanent: Mix.env() == :prod,
    deps: deps(),
    releases: releases()
  ]
  end
  def application do
    [
      extra_applications: [:logger],
      mod: {Foo.Application, []}
    ]
  end
  defp deps do
    [
      {:burrito, "~> 1.0"}
    ]
  end

  def releases do
    [
      foo: [
        steps: [:assemble, &Burrito.wrap/1],
        burrito: [
          targets: [
            macos: [os: :darwin, cpu: :x86_64]
            # linux: [os: :linux, cpu: :x86_64],
            # windows: [os: :windows, cpu: :x86_64]
          ]
        ]
      ]
    ]
  end
end

To run a development build I use mix app.start. To build a release build I use:

MIX_ENV=prod mix release --overwrite
yes | burrito_out/foo maintenance uninstall
# The release build is in burrito_out/foo.

More on the maintenance uninstall later…

Accessing command-line arguments

When you run your application using mix app.start arg1 arg2, arguments will show up in System.argv(). But confusingly, when you run the binary created by mix release, System.argv() is empty. I asked how people get around this in IRC and got crickets!

I still don’t know how to get arguments in the binary created by mix release, but in the binary created by Burrito you can use Burrito.Util.Args.get_arguments(). So putting it all together:

args =
  case {System.argv(), Burrito.Util.Args.get_arguments()} do
    {[], args} -> args
    {[_ | args], _} -> args
  end

The tail pattern match is because Burrito.Util.Args.get_arguments() excludes the application name while System.argv() does not.

Clearing old versions of applications unpacked by Burrito

I built a new version of my application using MIX_ENV=prod mix release and was surprised that executing burrito_out/foo launched the old version of my application, even after rm -rf _build burrito_out! It turns out that this is because Burrito works by unpacking your application to somewhere on disk when you first run the binary. It removes the old files when the version number changes but not if the version number is the same. I haven’t bothered incrementing my application version from 0.1.0, so Burrito didn’t know it needed to remove the old files.

I think it’d be nice if Burrito could track not just a version number, but some statistics about the packed application binary, e.g. its hash. But fortunately the workaround is simple: you can either run mix release without MIX_ENV=prod (which will also make Burrito output a ton of debug logs when your application runs), or use burrito_out/foo maintenance uninstall to delete the old unpacked files.

I just added that to the script I use to build new release binaries so I don’t forget it in the future.

Dialyzer complains that start/2 has no local return

I think this is because start/2 ends with System.halt(0) as recommended by the Burrito documentation, which says that otherwise my application will just keep running after it’s finished. I just suppressed the warning:

@dialyzer {:nowarn_function, start: 2}
def start(_, _) do
  # ...
end

Logging things for debugging

IO.inspect is really useful. It’s the rough equivalent of console.log in JavaScript and println!("{:#?}", foo) in Rust in that it prints out a pretty representation of the structure of the object you give it.

Unlike JavaScript you can’t pass multiple arguments to IO.inspect, e.g. IO.inspect(foo, bar). But you can do the same trick I often use in JavaScript to associate names with the objects you’re logging:

IO.inspect({foo, bar})

In JavaScript this is especially nice if you have variables named foo and bar because you get object keys for free through punning. In Elixir {foo, bar} is a tuple; %{:foo => foo, :bar => bar} would get you the equivalent object. FWIW it sounds like José Valim, the creator of Elixir, decided against supporting punning for now.


Overall I’m having a wonderful experience with Elixir so far! Some parts are a little rough around the edges but the people on the Elixir Programming Language Forum have been exceptionally helpful and friendly.