You might not need gradual typing in Elixir

Phoenix on Rails is a comprehensive guide to Elixir, Phoenix and LiveView for developers who already know Ruby on Rails. If you’re a Rails developer who wants to learn Phoenix fast, click here to learn more!

A couple of days ago, José Valim made a long-awaited announcement:

That’s right: roughly 18 months since the topic was first broached at ElixirConf EU, Elixir is getting a typing system. The first version (which as far as I can tell hasn’t actually been released yet; presumably it’s coming in Elixir v1.17) won’t involve any new syntax; instead it’s an “under the hood” improvement that will catch type errors at compile time that previously would have crashed your app at runtime.

In theory this is the first step towards a more fully-featured type system which you can read about here. This can only be a good thing for adoption - “It doesn’t have static types!” is easily the most common reason I hear people give for why they’re afraid to try Elixir. Pretty soon they’ll have no more excuses.

But honestly, I’ve always felt like this objection was overblown. I’ve got nothing against static typing; some of my best friends use statically-typed programming languages. And I’ve written enough Ruby to have experienced every last way in which dynamic typing can ruin your day. (Would you like another undefined method exception on that NilClass?) But my experience with Elixir is that it’s not like the other dynamic languages.

This might be hard to believe, but once you’re comfortable with its functional paradigm, Elixir’s syntax, semantics and tooling make typing issues pleasantly rare. Sure, you don’t get the full benefits of a static typing system, but you might be surprised by how little you miss it.

Immutability

In Elixir, everything is immutable. Once you’ve created a data structure, nothing can change it. This eliminates a huge source of bugs because it gives you confidence in your data.

For example, consider the following Ruby that creates a string, passes it to another method then prints it.

name = "George"
foobar(name)
puts name

What gets printed? Without looking at the definition of foobar we can’t be sure, because foobar might mutate its input, for example by converting it to uppercase:

def foobar(str)
  str.upcase!
end

name = "George"
foobar(name)
puts name
# GEORGE

And even if our code works today, it might break tomorrow when someone changes the definition of foobar.

In Elixir we don’t have this problem. foobar can’t change the value of name because nothing can change it; it’s immutable:

name = "George"
foobar(name)

# This can only print "George":
IO.puts(name)

Immutability keeps the app’s execution predictable and transparent, reducing the chance of side effects that could lead to elusive bugs. Like static typing, it helps you introduce new features or refactor existing ones with a greater assurance that existing functionality remains unaffected.

Learn Phoenix fast

Phoenix on Rails is a 72-lesson tutorial on web development with Elixir, Phoenix and LiveView, for programmers who already know Ruby on Rails.

Get part 1 for free:

Pattern matching and guards

Pattern matching is one of my favorite things about Elixir. It makes my code so much more elegant and concise - and it also provides us with the rudiments of a type system.

Consider, for example, the following function that adds a comment to a blog post:

def add_comment(%BlogPost{published: true} = post, comment_attrs) do
  %Comment{post_id: post.id}
  |> Comment.changeset(comment_attrs)
  |> Repo.insert()
end

def add_comment(%BlogPost{}, _), do: {:error, "Post is not published"}

By matching on the :published attribute, we enforce the condition that comments can only be added to published posts. And as a bonus, we enforce the function’s type signature: if you pass anything other than a %BlogPost{} as the first argument to add_comment/2, you’ll get an error.

Granted, this error won’t happen until runtime, whereas a static language would catch the problem at compile time. But in practice, your IDE still has enough information to spot the problem as soon as you type it and give you a warning.

We get similar benefits using guard clauses:

defmodule MyApp.AccountManagement do
  alias MyApp.User
  alias MyApp.Repo

  def update_user_profile(user_id, attrs) when is_map(attrs) do
    User
    |> Repo.get!(user_id)
    |> User.change_profile(attrs)
    |> Repo.update()
  end
end

defmodule MyApp.ProfileController do
  use MyAppWeb, :controller

  alias MyApp.AccountManagement

  def update_email(conn, %{"id" => id, "email" => email}) do
    case AccountManagement.update_user_profile(id, email: new_email) do
      {:ok, _user} ->
        redirect(conn, to: ~p"/profile")

      {:error, changeset} ->
        render(conn, changeset: changeset)
    end
  end
end

Do you spot the problem? The when clause on AccountManagement.update_user_profile/2 ensures that the second argument is a map - but when I call this function from my controller, I mistakenly passed the second argument as the keyword list [email: new_email].

If this was Rails, I’d be out of luck - but again, Elixir is smart enough to warn me straightaway. Who needs static typing?

Pure functions

A pure function is a function that a) always returns the same outputs for the same inputs and b) has no side effects - that is, it doesn’t affect or depend on anything else on the system, such as the database or global variables.

For example, the following function will always return 27 for the input 3, no matter where and when it’s called:

defmodule Mathematics do
  def cube(x) do
    x * x * x
  end
end

Pure functions are easy to reason about because you can understand exactly what they always do just by reading their definitions, that’s all. Thus they reduce complexity and the potential for errors.

You can’t compose an application entirely out of pure functions; at some point you’re going to need to make a side effect (such as writing to a log) or depend on external state (such as the return value of an API call.) But the functional style encourages you to keep your functions as pure as possible, avoiding some of the pitfalls of a dynamic language.

Sensible operators

Look at this simple Ruby method:

def add(a, b)
  a + b
end

Suppose we were trying to programmatically detect typing errors in our Ruby, and we wanted to figure out the type signature of add. From the name we might guess that it takes two numbers and returns another number:

add(1, 2)
# => 3

But Ruby’s + operator is highly overloaded, meaning add can do all kinds of other things:

# String concatenation:
irb> add("a", "b")
# => "ab"

# Array concatenation:
irb> add([1, 2], [3, 4])
# => [1, 2, 3, 4]

# Date and time arithmetic:
irb> Date.new(1815, 3, 20) + 110
=> #<Date: 1815-07-08 ((2384163j,0s,0n),+0s,2299161j)>

So there’s not much we can say about the type signature of add. It can take two numbers, dates, strings, arrays, or basically anything else.

Operator overloading isn’t unambiguously a bad thing. Defenders will argue that it makes Ruby more intuitive, expressive and beautiful. But it can lead to subtle errors: suppose, for example, that you accidentally pass two strings to add when you meant to pass two numbers:

irb> add("1", "2")
"12"

This is erroneous, but it hasn’t raised an exception. The dodgy string will continue on its way, being passed from function to function, and by the time you see a “real” error - the type that raises an exception, or worse - it might be in a completely different part of your codebase. You’ll scratch your head as you look back through the stacktrace, trying to figure out where it all went wrong.

In Elixir, we don’t have this problem, because operators aren’t overloaded. Addition, string concatenation, list concatenation and date arithmetic can’t be confused, because they each use a different operator (or, in the case of date arithmetic, a function):

iex> 1 + 2
3

iex> "a" <> "b"
"ab"

iex> [1, 2] ++ [3, 4]
[1, 2, 3, 4]

Date.add(~D[1815-03-20], 110)
~D[1815-07-08]

Again, we have the traces of a type system. We don’t need to explicitly tell the compiler what our types are because we don’t need to - it’s implied by our choice of operator. It’s a subtle improvement, but these small things add up. (Or maybe they concatenate?)

In summary

What these features have in common is that they reduce ambiguity. So many bugs, especially the ones that slip past testing, are caused by things we don’t see; changes in one part of the system that unexpectedly impact another part which you don’t notice until your customers complain that it’s broken.

The functional paradigm protects us by erecting guardrails - it enforces, or at least encourages, a clean and maintainable programming style, where logic is grouped into coherent, encapsulated functions that interact in predictable ways. It’s harder to break something that you’re not looking at, because it’s harder to write code that affects anything except the file in front of you.

For a little bit of extra verbosity and a little bit less flexibility, you get fewer bugs, fewer headaches, and a lot more confidence in your changes. Which is the same kind of trade-off that you get with a static type system.

And I haven’t even mentioned Dialyzer, the Erlang-based static analysis tool that can help to catch a lot of type (and other) issues. Elixir typespecs also help to document your code and catch type errors, especially when combined with tools like Dialyzer.

The upcoming introduction of gradual typing can only be a good thing for Elixir, and I hope it convinces more developers and companies to take a chance on my favorite programming language (especially if it makes them buy my course 😉). But in the meantime, what are you waiting for?

Learn Phoenix fast

Phoenix on Rails is a 72-lesson tutorial on web development with Elixir, Phoenix and LiveView, for programmers who already know Ruby on Rails.

Get part 1 for free: