Elixir : Basics of Type Specifications

Arunmuthuram M
9 min readDec 13, 2023

--

Type specifications, also referred as typespecs, are meta data in form of notations that describe the signature of an elixir function. They provide information about a function’s signature such as the number of arguments it takes; the data type, structural information and even the exact literal value of each of its arguments and its return value. Even though elixir is a dynamically typed language and specifications about data types will not be used or enforced during compilation, they serve as an important form of documentation. They can also be used with static analysis tools like dialyzer that can read the typespecs of a function and identify definitive type errors, which occurs when using an argument or a return value with a data type or structure different from the function’s typespec definitions. This article is on the basics, syntax and usage of type specifications in elixir.

Syntax

A typespec definition for a function signature is written above the function’s definition and it starts with a @spec attribute followed by the function name, parentheses that contain information about each argument separated by a comma, a :: separator and finally the information about the return value.

defmodule Test do
@spec test(arg1_type_info, arg2_type_info) :: return_value_info
def test(a, b), do: a + b
end

Now that we have seen the general syntax of a typespec, let’s deep dive into the specifics of how an argument or the return value is represented within the typespec notation. The primary information about an argument or the return value is its data type. Typespecs include notations for different basic built-in types of the same name, e.g. atom, map etc. It is also common to add empty parentheses to all type notations even if they are simple types, e.g. integer() , atom().

defmodule Test do
@spec add(integer, integer) :: integer
def add(a, b), do: a + b

@spec multiply(integer(), integer()) :: integer()
def multiply(a, b), do: a * b
end

For collection types that have elements inside them, parenthesis containing type information of the elements can also be used, e.g. list(atom) indicating a list of atoms.

defmodule Test do
@spec list_sum(list(integer)) :: integer
def list_sum(nums), do: Enum.sum(nums)
end

Elixir also provides the types, any that represents an elixir term that can be of any type and also none which is mostly used as the return value when a function returns nothing.

defmodule Test do
@spec list_count(list(any)) :: integer
def list_count(list), do: Enum.count(list)

@spec raise_exception(String.t) :: none
def raise_exception(error), do: raise error
end

When the function takes in more than one argument, in order to make the notation more readable, the respective parameter names of these arguments can be used along with their types, separated by the :: syntax.

defmodule Test do
@spec add(a :: integer, b :: integer) :: integer
def add(a, b), do: a + b

@spec list_sum(nums :: list(integer)) :: integer
def list_sum(nums), do: Enum.sum(nums)
end

Typespecs also support literal values such as the atoms, :ok, :error, true, nil etc and literals of other data types as well.

defmodule Test do
@spec inspect_all(list(any)) :: :done
def inspect_all(list) do
Enum.each(list, &IO.inspect/1)
:done
end
end

When an argument or the return value can be of more than one specific type, then a notation denoting a union of types can be created by separating the multiple types with the | symbol, for e.g. {:ok | :error, integer | nil} denotes a two element tuple where the first element could be the atom literal :ok or :error and the second element could be of type integer or the term nil.

defmodule Test do
@spec max_double(a :: integer, b :: integer) :: {:ok | :error, integer | String.t}
def max_double(a, b) do
cond do
is_integer(a) and is_integer(b) -> {:ok, max(a, b) * 2}
true -> {:error, "Invalid argument"}
end
end
end

For functions with multiple clauses, different specs can be created for different clauses.

defmodule Test do
@spec size(bin :: binary) :: non_neg_integer
def size(bin) when is_binary(bin), do: byte_size(bin)

@spec size(map :: map) :: non_neg_integer
def size(%{} = map), do: map_size(map)
end

Using invalid undefined types or invalid specs with wrong function name or wrong arity will lead to compile time errors.

defmodule Test do
@spec add(a :: int, b :: integer) :: integer
def add(a, b), do: a + b
end

** (Kernel.TypespecError) iex:30: type int/0 undefined (no such type in Test)
-----------------------------------------------------------------------------
defmodule Test do
@spec wrong_name(a :: integer, b :: integer) :: integer
def add(a, b), do: a + b
end

error: spec for undefined function wrong_name/2
-----------------------------------------------------------------------------
defmodule Test do
@spec add(a :: integer, b :: integer, c :: integer) :: integer
def add(a, b), do: a + b
end
error: spec for undefined function add/3

Custom types

Elixir allows creation of custom types that can be composed from the allowed existing basic types and previously created user-defined custom types. Custom types allow you to define complex structures just once, tag them with readable and relevant names, reuse them and build more custom types on top of them. A custom type can be created using one of the three attributes, @type for creating public custom types that can be used in other modules, @typep for creating private custom types which are used only within the module and @opaque used for creating public types whose internal structure is not exposed in the specs. The syntax for creating types involves using one of the above attributes, followed by the custom type name, the :: separator and then the type’s notation. Just like accessing a module’s named functions, the public custom types can be accessed in other modules using the Module.type_name syntax. It is a common convention to name the user-defined custom struct types as t and refer them using the ModuleName.t syntax. You can also provide documentation for the defined custom types using the @typedoc module attribute.

defmodule Pixel do
@typep intensity :: 0..255

@typedoc "Representation of pixel with intensity values for RGB channels"
@type t :: %Pixel{red: intensity, green: intensity, blue: intensity}

defstruct [red: 0, green: 0, blue: 0]
end
---------------------------------------------------------------------------
defmodule ImageProcessor do
@spec invert_pixel(Pixel.t) :: Pixel.t
def invert_pixel(%Pixel{red: red, green: green, blue: blue} = pixel) do
%Pixel{pixel | red: (255 - red), green: (255 - green), blue: (255 - blue)}
end
end

Typespecs also allow guard notations to denote the type information of the function parameters at the end of the notation. This is an alternative for the usual syntax where the types of arguments are denoted within the parenthesis. This can be used to inline the type definition of arguments within the typespec. If the argument types won’t be used anywhere else, then instead of creating a separate custom type, the types can be defined inline using the guard notation. The guard notation can be used with the syntax,
when arg1: type, arg2: type .

defmodule Pixel do
defstruct [red: 0, green: 0, blue: 0]
end
---------------------------------------------------------------------------
defmodule ImageProcessor do
@spec invert_pixel(pixel) :: pixel
when pixel: %Pixel{red: intensity, green: intensity, blue: intensity},
intensity: 0..255
def invert_pixel(%Pixel{red: red, green: green, blue: blue} = pixel) do
%Pixel{pixel | red: (255 - red), green: (255 - green), blue: (255 - blue)}
end
end

Types: syntax and variations

Let us now look at elixir’s various built-in types, custom types, their aliases and their syntax variations.

General types

any() :: term() # any elixir term

no_return() :: none() # denotes no term, used in functions that do not
# return any value

Number types


integer() # denotes an integer

float() # denotes a floating point number

number() :: integer() | float() # denotes an integer or a float

neg_integer() # denotes a negative integer

non_neg_integer() # denotes zero and positive integers

pos_integer() # denotes only positive integers

1 # denotes integer literal

1..20 # denotes integer range of numbers

byte() :: 0..255 # denotes an integer within the byte range

arity() :: 0..255 # denotes arity of a function

char() :: 0..0x10FFFF # denotes codepoint of a character


Atom types

atom() # denotes an atom

:ok # denotes an atom literal

true | false | nil # special atom literals

boolean() :: true | false # denotes the special atom literal true or false

as_boolean(t) # denotes that the type t will be treated as a
# truthy/falsy value

module() :: atom() # denotes module name as atom

mfa() :: {module(), atom(), arity()} # denotes module name, function name
# and function arity

node() :: atom() # denotes a node as atom


List and keyword list types

[] # denotes an empty list

list(type) :: [type] # denotes a proper list with any size and elements
# of given type

list() :: list(any()) :: [any] # denotes a proper list of any number of
# elements of any type

nonempty_list(type) # non-empty proper list with size > 0 and elements
# of given type

nonempty_list() :: nonempty_list(any())

[...] # non_empty proper list with size > 0 and elements of any type

[type, ...] # non_empty proper list with size > 0 and elements of given type

# An improper list will not contain an empty list as the last element.
# It is used explicitly in handing iolists. E.g. [1 | [2]] or [1 | [2 | []]]
# is a proper list, while [1 | [2 | 2]] is an improper list.
# The type of the last element is denoted by the termination_type and
# the types of the rest of the elements is denoted by content_type.
# If the termination type is an empty list then the list is proper.

maybe_improper_list(content_type, termination_type) # proper/improper
# list of any size of contents and termination element of given types

maybe_improper_list() :: maybe_improper_list(any(), any())

nonempty_improper_list(content_type, termination_type) # non-empty improper
# list of size > 0 with contents and termination element of given types

nonempty_maybe_improper_list(content_type, termination_type) # non-empty
# proper/improper list of any size of contents and termination element
# of given types

nonempty_maybe_improper_list() :: nonempty_maybe_improper_list(any(), any())

charlist() :: [char()] :: string() # denotes a charlist

nonempty_charlist() :: [char(), ...] # non empty charlist of size > 0

iolist() :: maybe_improper_list(byte() | binary() | iolist(), binary() | [])
# denotes an proper/improper list with content_type as either byte, binary
# or another iolist and the termination_type as either binary() or an empty
# list

iodata() :: iolist() | binary() # denotes iolist or binary


----------------------------------------------------------------------------

[key: type] # keyword list with optional atom literal key, :key with
# value of given type

keyword() :: [{atom(), any()}] # denotes a keyword list with atom literals
# as keys and values of any type

keyword(type) :: [{atom(), type}] # denotes a keyword list with atom literals
# as keys and values of given type

@type option :: {:key1, type} | {:key2, type} | {:key3, type}
@type options :: [option()] # denotes keyword list from custom types.
# The only allowed entries in the keyword list are the three option types.
# The allowed {key, value} entries can be of any order and the keyword list
# can be empty.

Tuple types

tuple() # denotes a tuple of any number of elements of any type

{} # denotes an empty tuple

{:ok, type} # denotes a two-element tuple with first element as atom
# literal, :ok and the second element of given type

Map and struct types

map() :: %{optional(any) => any} # denotes a map with optional keys and
# values of any types

%{} # denotes empty map

%{key: type} # map with required atom literal key, :key with value of
# given type

%{type => type} :: %{required(type) => type} # map with required pairs of
# {keys, values} of given type

%{optional(type) => type} # map with optional pairs of {keys, values}
# of given type

%{type => type, optional(type) => type} # map with required and optional
# {key, value} pairs of given types

%type{} # Struct with all field values of any type

%type{key: type} # Struct with required atom literal key, :key and
# value of given type

Bitstring types

<<>> # denotes an empty bitstring

<<_::size>> # bitstring of size 0 or more

<<_::_*unit>> # bitstring of size units where unit can be 1..256

<<_::size, _::_*unit>> # bitstring of two segments

binary() :: <<_::_*8>> # denotes a binary whose size units are 8 bits.
# i.e bit size is divisible by 8.

nonempty_binary() :: <<_::8, _::_*8>> # binary with byte size > 0

bitstring() :: <<_::_*1>> # bitstring with size units as 1

nonempty_bitstring() :: <<_::1, _::_*1>> # non empty bitstring with
# bit size > 0

String.t() :: denotes utf-8 encoded binary or elixir string

Anonymous function types

(-> type)  # zero-arity function that returns value of given type

(type1, type2 -> type) # two-arity function of given argument types, |
# returns value of given type

(... -> type) # function of arguments of any arity and type, returns value
# of given type

function() :: fun() :: (... -> any) # function of arguments of any arity
# and type, returns value of any type

Identifier types

pid() # denotes process identifier

port() # denotes port identifier

reference() # denotes a reference

identifier() :: pid() | port() | reference() # denotes one of the
# identifier types

--

--