Elixir v1.20 released: now a gradually typed language

In 2022, we announced an effort to add set-theoretic types to Elixir. In June 2023, we published an award-winning paper on Elixir’s type system design and said our work was transitioning from research to development.

With Elixir v1.20, we have completed our first development milestone, which is to introduce type inference without introducing type annotations and gradually typing every Elixir program. This means that Elixir is increasingly reporting dead code. verified bug: Typing violation that is guaranteed to fail at runtime if executed. Elixir can find verified bugs in existing programs efficiently, with no developer overhead, and with an extremely low false positive rate.

In this announcement, we will describe the types of system goals, what dynamic() What does type mean in Elixir, and how to find it out verified bug. In particular, our implementation performs well in the “If T: Benchmark for Type Narrowing” benchmark. Elixir passes 12 of the 13 categories, showing that it can recover the exact type information from normal Elixir code that we use to find verified bugs in dynamically typed programs.

The type system was made possible thanks to a partnership between CNRS and Remote. The development work is currently sponsored by Fresha and Tidewave.

Type, in my nectar?

Our goal is to introduce a type of system that is:

  • sound – The types inferred and specified by the type system align with the program’s behavior.
  • gradual – Types of nectar included in the system dynamic() Type, which can be used when checking the type of a variable or expression at runtime. in the absence of dynamic()Elixir’s type system behaves as a constant
  • developer friendly – Types are described, implemented, and composed using basic set operations: union, intersection, and negation (so it is a set-theoretic type system), with clear error messages.

Introducing a type system into an existing language is a complex change. For this reason, our first milestone was to implement the type system without introducing typing annotations, but that still provides value to developers by finding dead code and verified bugs. is done through dynamic() type, which is quite different from other slowly typed languages ​​in Elixir. Let’s break it down.

dynamic() Type

There are many sequential types of systems any() Type, which, from a type system perspective, often means “anything goes” and no type violations are reported. On the other hand the gradual type of nectar is called dynamic() And it has two important properties: compatibility and contraction.

In static type systems, when you have a type of size integer() or binary() And you invoke a function, said function must accept both types. However, because type systems cannot accurately capture the intent of all our programs, this can lead to false positives. For example, take the simple code below:

def percentage_or_error(value) when is_integer(value) do
  value_or_error =
    if value > 1 do
      value
    else
      "not well"
    end

  # ... more code ...

  if value > 1 do
    value_or_error / 100
  else
    String.upcase(value_or_error)
  end
end

Although value_or_error type is integer() or binary()operator / accepts only numbers, and String.upcase Accepts only binaries/strings, the above program is valid and throws no exceptions at runtime. However, a type system will still report two violations, because the type is supplied / And String.upcase Are not subtypes of accepted types.

Although the program above could be optimally written so that no typing violations occur, the type system will always reject valid programs, and if Elixir introduces too many false positives into the existing codebase, it will quickly erode trust in the type system. Therefore, Elixir’s sequential type system tags value_or_error above variable with type dynamic(integer() or binary())which means that the type is either integer() or binary() at runtime.

When calling a function with a dynamic() Type, Elixir will only emit typing violations if the supplied type and the accepted type are unrelated. Although in the above program / Expects only numbers, dynamic(integer() or binary()) could be the one integer() And given that the accepted and supplied types are not disjoint, there is no typing violation. However, if we had to change the program to:

value_or_error =
  if value > 1 do
    value
  else
    "not well"
  end

Map.fetch!(value_or_error, :some_key)

Because Map.fetch! Expects a map data structure, and value_or_error At runtime there can only be integer or binary, the accepted and supplied types are disjointed, which turns into a violation. This is known as the compatibility property and explains how Elixir reports only verified bug.

However, reporting only verified bugs will not be useful if we do not find many bugs the first time. We solved this problem by ensuring that Elixir’s dynamic types could be limited. Take this code:

def add_a_and_b(data) do
  data.a + data.b
end

In the above program, data starts as dynamic() Type. then we use this data.a And data.b inside the plus operator, so Elixir will refine data variable to type %{..., a: number(), b: number()}which means it is a map with both a And b Fields with number values ​​(and potentially any other fields, hence the leading). ...). So, if you forget to select .b Create a field and write this:

def add_a_and_b(data) do
  data.a + data
end

data The first will be limited to a map of size %{..., a: number()}Then an attempt was made to use it as a number()Which will cause violation.

In other words, dynamic() The type in Elixir effectively works as a range, which can be refined as it is used throughout the program and reports violations whenever the type check is out of range. This is in contrast to other sequential type systems, which use dynamic types to discard all type information.

Behind the scenes, our type inference and type checking algorithms behave as if we have annotated all the argument types. dynamic(). Once we introduce user-supplied type annotations, Elixir’s type system will behave as any statically typed language as long as dynamic() is not used. And whenever you cross the static-dynamic boundary, we’ve developed new techniques that ensure our sequential typing is good without the need for additional runtime checks.

Typing guards, clauses and more

Much of the work behind this release was to introduce type checking and compression across multiple builds. Let’s look at some of them.

When it comes to protectors, we can anticipate unions, contradictions and prohibitions:

def example(x, y) when is_list(x) and is_integer(y)

The above code makes the correct guess x There is a list and y is an integer.

def example({:ok, x} = y) when is_binary(x) or is_integer(x)

The above assumes that x is a binary or integer, and y is a two-element tuple :ok as the first element and a binary or integer as the second.

def example(x) when is_map_key(x, :foo)

The above code estimates x There is a map which has :foo key, depicted as %{..., foo: dynamic()}. remember the pioneer ... indicates that the map may contain other keys.

def example(x) when not is_map_key(x, :foo)

and the above code estimates x There is a map that does not have :foo Key, whose type is: %{..., foo: not_set()}. like this x.foo A typing violation will occur within the function body.

You can also have expressions that emphasize the size of data structures:

def example(x) when tuple_size(x) < 3

Elixir will correctly track that the tuple has at most two elements, and hence is accessible elem(x, 3) Will emit typing violation. For maps and lists, we convert the size check into an emptyness check. In other words, Elixir can look at complex guards, infer types, and use this information to find bugs in our code.

When it comes to constructions like case And conditional, Elixir uses information from previous sections to refine the next sections:

case System.get_env("SOME_VAR") do
  nil -> :not_found
  value -> {:ok, String.upcase(value)}
end

System.get_env("SOME_VAR") either returns nil or a binary(). because the first clause matches nilknows the type system value can’t happen now nilAnd so it should be only one binary()Which also allows the second section to type the check without violation. Collapsing segments also helps the type system find unnecessary segments and dead code in the existing codebase.

In addition, we have added several functions to the standard library that work with tuples and maps. You can find more details in the release notes.

improve compile time

Elixir v1.20 once again improves compile times, especially on applications running on machines with multiple cores. Even though BEAM languages ​​are efficient to compile in general, our synthetic benchmarks now find Elixir’s build tool to be the fastest of them. If you would like to contribute more examples and scenarios, please start a discussion so we can provide a transparent suite of benchmarks and results.

It also introduces a new compiler option called :module_definitionwhich specifies whether a module definition should be :compiled (default) or :interpreted. This may improve compile time in larger projects and has no impact .beam Files are written to disk, only the contents inside are defmodule are executed. You can enable it by setting elixirc_options: [module_definition: :interpreted] in your mix.exs. Read the documentation to learn more.

what is next?

The biggest question we face is: when will Elixir introduce new type signatures that take advantage of set-theoretic types? As discussed recently in my ElixirConf EU 2026 keynote, we still have both research and development work to do. We will present only types of signatures:

  • If we are satisfied with the type system performance in Elixir v1.20 (and we have done extensive work to optimize it)
  • If we can implement recursive types efficiently
  • If we can implement parametric types efficiently
  • If we can implement maps key-value pairs computably efficiently (we are still researching possible solutions here)

Once those problems are resolved, we will begin to explore and discuss typed structure definitions and finally type signatures. As always, we’ll keep the community engaged through news and posts in the Elixir forum.

We appreciate everyone who tried release candidates, ran benchmarks, and gave us feedback! Try Elixir v1.20 and remember to fix all the bugs you find in it for free!



<a href

Leave a Comment