Appearance
Gradual set-theoretic types
Source: Elixir reference, Gradual set-theoretic types.
Elixir is incorporating set-theoretic types into the compiler. This page describes the current stage. The type system is:
- sound: the types it infers and assigns agree with how the program behaves
- gradual: it has a
dynamic()type for things checked at runtime. Butdynamic()works as a range:dynamic(integer() or binary())still reports a violation if none of those types is accepted. Withoutdynamic(), the system behaves as a static one - developer friendly: types are built with set operations: union, intersection and negation
The current milestone infers types from existing programs and uses them for type checking, so the compiler finds bugs without changes to your code. User-provided signatures are planned for later.
A gentle introduction
Types are written like calls: integer(), list(integer()). The basic types are atom(), binary(), bitstring(), empty_list(), integer(), float(), function(), map(), non_empty_list(elem_type, tail_type), pid(), port(), reference() and tuple().
Many can be written more precisely: :ok for one atom, {:ok, integer()} for a two-element tuple. Three special types: none() (the empty set), term() (everything) and dynamic() (a range).
Compose them with or, and and not. atom() or integer() is either. atom() and integer() is the empty set, none(). A difference combines the two: all atoms except nil is atom() and not nil. The full syntax is in the types cheatsheet.
The syntax of data types
Developers meet these types mainly through compiler warnings.
Broad types can't name individual elements: binary(), bitstring(), integer(), float(), pid(), port(), reference(). binary() is a subtype of bitstring(), since a binary is a bitstring whose bit count is divisible by 8.
Atoms: atom() for all, or each literal (:foo, nil, true, false) as its own type. boolean() is true or false.
Tuples: tuple(), or {:ok, binary()}. A trailing ... means the size is unknown: {:ok, binary(), ...} has at least two elements.
Lists: list() is all proper lists including []. list(integer()) includes [] and [1, 2, 3], but not [1, "two", 3]. Internally list(a) is empty_list() or non_empty_list(a).
Improper lists
A second argument to non_empty_list is the type of the tail. A proper list's tail is the empty list. If the tail type is not a list, the list is improper. A list tail is merged into the element type.
Maps
map() is all maps (the same as %{...}). Literal syntax:
elixir
%{name: binary(), age: integer()} # closed: exactly these keys
%{..., name: binary(), age: integer()} # open: these keys, other keys allowedempty_map() is the empty map (%{} also works). Optional keys use if_set/1: %{name: binary(), age: if_set(integer())} certainly has :name and may have :age. not_set() says a key is absent: %{..., age: not_set()} is the type Map.delete(map, :age) returns.
Domain keys are types instead of atoms (%{binary() or atom() => integer()}, or open with ...). The system tracks only the top of each type: %{list(integer()) => integer(), list(binary()) => binary()} is the same as %{list() => integer() or binary()}. Supported domains: atom(), bitstring(), binary(), integer(), float(), fun(), list(), map(), pid(), port(), reference(), tuple(). Domain keys are always optional (you can't store every integer as a key), so fetching one may find nothing.
Mixed keys combine both, ordered by increasing precision, like duplicate keys overriding earlier ones at runtime: %{atom() => binary(), root: integer()}.
Functions
function() is all of them. An arrow describes one: (integer() -> boolean()), (integer(), integer() -> binary()). Several clauses mean an intersection:
elixir
def negate(x) when is_integer(x), do: -x
def negate(x) when is_boolean(x), do: not xThis function has type (integer() -> integer()) and also (boolean() -> boolean()), so its type is (integer() -> integer()) and (boolean() -> boolean()). It belongs to both sets.
Why an intersection and not a union? Take a shirt with green and yellow stripes. It is in the set of shirts with green, and in the set of shirts with yellow. "Green or yellow" is true but includes shirts that are only green. "Green and yellow" captures both facts. In practice a union of two functions is not useful, and the compiler points you to the right one.
The dynamic() type
Existing programs have no type declarations, so Elixir types negate/1 as (dynamic() -> dynamic()). Patterns and guards then refine x to dynamic() and integer() and dynamic() and boolean() in each clause. That makes dynamic() a gradual type, hence gradual set-theoretic types.
Think of dynamic() as a range of types. With var of type atom() or integer(), Integer.to_string(var) warns, because it doesn't accept atoms. With dynamic() and (atom() or integer()) (written dynamic(atom() or integer())) there is no warning, since the call works for at least one type.
Compared with other gradually typed languages, dynamic() here is powerful: it restricts the program via intersections and still warns once failure is certain. If you provide your own non-dynamic() types, the system is fully static.
Dynamic types are always at the root. {:ok, dynamic()} is rewritten to dynamic({:ok, term()}). You can't make just part of a tuple, map or list gradual, but dynamic() can never sneak into statically typed code.
Type inference
Inference deduces types at compile time. It has trade-offs:
- Speed: inference is often more expensive than checking.
- Expressiveness: what can be inferred is a subset of what can be checked.
- Incremental compilation: if A depends on B depends on C, a change in C may change B's signature and force A to be recomputed.
- Cascading errors: conflicting assumptions can produce less clear messages.
Elixir's inference is best effort. It finds bugs where all combinations of a type fail, without guaranteeing to find every incompatibility. It infers across the standard library and your dependencies, and treats calls to modules in the same project as dynamic(). Later, functions with explicit signatures will be checked against them, as in static languages.
False positives
Inference avoids reporting violations where no runtime error would happen, with two documented exceptions:
forassumes it runs at least once. Infor _i <- list do Atom.to_string(x) endfollowed byx + 1, the checker assumesxis an atom. Iflistis empty there's no runtime error, but it still warns. Wrap the loop inif list != [] do.- Struct update must be statically proven.
%User{user | name: "John Doe"}warns unless the type system can proveuseris aUser, even if it always is at runtime. Match when defining it:%User{} = user = find_user_by_id(42).
Resources
- Paper: The Design Principles of the Elixir Type System, Giuseppe Castagna, Guillaume Duboc and José Valim
- Video: The foundations of the Elixir type system, José Valim
- Video: Precision in type system design, José Valim
The type system comes from a partnership between CNRS and Remote. Development is sponsored by Fresha and Tidewave.