Type (mavis v0.0.3) View Source
Type analysis system for Elixir.
Mavis implements the Type
module, which contains a type analysis system
specifically tailored for the BEAM VM. The following considerations went
into its design.
- Must be compatible with the existing dialyzer/typespec system
- May extend the typespec system if it's unobtrusive and can be, at a minimum, 'opt-out'.
- Does not have to conform to existing theoretical typesystems (e.g. H-M)
- Take maximum advantage of Elixir programming features to achieve readability and extensibility.
- Does not have to be easily usable from erlang, but must be able to handle modules produced in erlang.
Compile-Time Usage
The type analysis system is designed to be a backend for a compile-time typechecker (see Selectrix). This system will infer the types of functions in modules and emit errors and warnings if there appear to be conflicts.
One key function enabling this is fetch_type!/3
; you can use this function
to retrieve typing information on typing information stored in modules that
have already been compiled.
iex> inspect Type.fetch_type!(String, :t, [])
"binary()"
Runtime Usage
There's no reason why you have to use the typing library exclusively at compile-time. Here is an example of using it at runtime:
defmodule TestJson do
@type json :: String.t | number | boolean | nil | [json] | %{optional(String.t) => json}
def validate_json(data) do
json_type = Type.fetch_type!(__MODULE__, :json, [])
if Type.type_match?(json_type, data), do: :ok, else: raise "not json"
end
end
Note that the above example is not particularly performant.
Examples:
iex> import Type, only: :macros
iex> inspect Type.union(builtin(:non_neg_integer), :infinity)
"timeout()"
iex> Type.intersection(builtin(:pos_integer), -10..10)
1..10
The Type
module implements two things:
- Core functionality for all typesytem operations.
- Support data structure for generic builtin and remote types.
Type representation in Mavis
The Type
data structure is a struct with three parameters:
module
: The module in which the type is defined;nil
for builtins.name
: (atom) of the typeparams
: a list of type arguments to the type data structure. These must be types themselves "as applied" to the type definition if we consider it to be a function.
Representing other types.
The following literals are represented directly:
- integers
Range
s (must be increasing)- atoms
- empty list (
[]
)
The following types have associated structs:
- lists:
Type.List.t/0
- bitstrings/binaries:
Type.Bitstring.t/0
- tuples:
Type.Tuple.t/0
- maps:
Type.Map.t/0
- funs:
Type.Function.t/0
The following "type containers" have associated structs:
- unions:
Type.Union.t/0
- opaque types:
Type.Opaque.t/0
- function vars:
Type.Function.Var.t/0
Supported Type operations
The Mavis typesystem provides five primary operations, which might not necessarily be the set of operations that one expects from a typesystem. These operations are chosen specifically reflect the needs of Erlang and Elixir's dynamic types and the type specification system provided by dialyzer.
The operation Type.type_match?/2
is also provided, which is a combination of
Type.of/1
and Type.subtype?/2
.
Deviations from standard Elixir and Erlang
The Curious case of String.t
String.t/0
has a special meaning in Elixir, it is a UTF-8 encoded
binary/0
. As such, it is special-cased to have some properties that
other remote types don't have out of the box. This sort of behaviour
may be changed to be extensible to custom types in a future release.
The nonexistent type String.t/1
is also implemented, with the type
parameter indicating byte-lengths for compile-time constant strings.
This is done entirely under the hood and should not otherwise affect
operations. If you encounter strange results, report them to the issue
tracker.
In the meantime, you may disable this feature by setting the following:
config :mavis, :use_smart_strings, false
"Aliased builtins"
Some builtins were not directly introduced into the typesystem logic, since
they are easily represented as aliases or composite types. The following
aliased builtins are usable with the builtin/1
macro, but will return false
with is_builtin/1
NB in the future the name of is_builtin
may change to prevent confusion.
term/0
arity/0
as_boolean/1
binary/0
bitstring/0
byte/0
char/0
charlist/0
nonempty_charlist/0
fun/0
function/0
identifier/0
iodata/0
keyword/0
keyword/1
list/0
nonempty_list/0
maybe_improper_list/0
nonempty_maybe_improper_list/0
mfa/0
no_return/0
number/0
struct/0
timeout/0
iex> import Type
iex> builtin(:timeout)
%Type.Union{of: [:infinity, %Type{name: :non_neg_integer}]}
iex> Type.is_builtin(builtin(:timeout))
false
Module and Node detail
The module/0
and node/0
types are given extra protection.
An atom will not be considered a module unless it is detected to exist
in the VM; although for usable_as/3
it will return the :maybe
result
if unconfirmed.
iex> import Type
iex> Type.type_match?(builtin(:module), :foo)
false
iex> Type.type_match?(builtin(:module), Kernel)
true
iex> Type.type_match?(builtin(:module), :gen_server)
true
iex> Type.usable_as(Enum, builtin(:module))
:ok
iex> Type.usable_as(:not_a_module, builtin(:module))
{:maybe, [%Type.Message{target: %Type{name: :module}, type: :not_a_module}]}
A node will not be considered a node unless it has the proper form for a
node. usable_as/3
does not check active node lists, however.
iex> import Type
iex> Type.type_match?(builtin(:node), :foo)
false
iex> Type.type_match?(builtin(:node), :nonode@nohost)
true
Link to this section Summary
Types
No members of this type will succeed in the operation.
type of group assignments
Represents that some but not all members of the type will succeed in the operation.
output type for Type.usable_as/3
Functions
helper macro to match on builtin types.
Types have an order that facilitates calculation of collapsing values into unions.
retrieves a typespec for a function, and converts it to a Type.t/0
value.
retrieves a stored type from a module, and converts it to a Type.t/0
value.
resolves a remote type into its constitutent type. raises if the type is not found.
see Type.fetch_type/4
, except raises if the type is not found.
outputs the type which is guaranteed to satisfy the following conditions
outputs the type which is guaranteed to satisfy the following conditions
guard that tests if the selected type is builtin
guard that tests if the selected type is remote
returns the type of the term.
helper macro to match on remote types
outputs whether one type is a subtype of itself. To be true, the following condition must be satisfied
true if the passed term is an element of the type.
The typegroup of the type.
outputs the type which is guaranteed to satisfy the following conditions
outputs the type which is guaranteed to satisfy the following conditions
Main utility function for determining type correctness.
Link to this section Types
Specs
error() :: {:error, Type.Message.t()}
No members of this type will succeed in the operation.
should typically result in a compile-time error.
output by Type.usable_as/3
Specs
group() :: 0..12
type of group assignments
Specs
maybe() :: {:maybe, [Type.Message.t()]}
Represents that some but not all members of the type will succeed in the operation.
should typically result in a compile-time warning.
output by Type.usable_as/3
Specs
t() :: %Type{module: nil | module(), name: atom(), params: [t()]} | integer() | Range.t() | atom() | Type.List.t() | [] | Type.Bitstring.t() | Type.Tuple.t() | Type.Map.t() | Type.Function.t() | Type.Union.t() | Type.Opaque.t() | Type.Function.Var.t()
Specs
output type for Type.usable_as/3
Link to this section Functions
Specs
helper macro to match on builtin types.
Example:
iex> Type.builtin(:integer)
%Type{name: :integer}
Usable in guards
Specs
Types have an order that facilitates calculation of collapsing values into unions.
Conforms to Elixir's compare
api, so you can use this in Enum.sort/2
For literals this follows the order in the erlang type system. Where one type is a strict subtype of another, it should wind up less than its supertype
Types are organized into groups, which exist as a fastlane for comparing
order between two different types (see typegroup/1
).
The order is as follows:
- group 0:
none/0
and remote types - group 1 (integers):
- [negative integer literal]
neg_integer/0
- [nonnegative integer literal]
pos_integer/0
non_neg_integer/0
integer/0
- group 2:
float/0
- group 3 (atoms):
- group 4:
reference/0
- group 5 (
Type.List.t/0
):params: list
functions (ordered byretval
, thenparams
in dictionary order)params: :any
functions (ordered byretval
, thenparams
in dictionary order)
- group 6:
port/0
- group 7:
pid/0
- group 8 (
Type.Tuple.t/0
):- defined arity tuple
- any tuple
- group 9 (
Type.Map.t/0
): maps - group 10 (
Type.List.t/0
):nonempty: true
list- empty list literal
nonempty: false
lists
- group 11 (
Type.Bitstring.t/0
): bitstrings and binaries - group 12:
any/0
Range.t/0
(group 1) comes after the highest integer in the range, with
wider ranges coming after narrower ranges.
iolist/0
(group 10) comes in the appropriate place in the list group.
a member of Type.Union.t/0
comes after the highest represented item in its union.
Examples
iex> import Type, only: :macros
iex> Type.compare(builtin(:integer), builtin(:pid))
:lt
iex> Type.compare(-5..5, 1..5)
:gt
retrieves a typespec for a function, and converts it to a Type.t/0
value.
Example:
iex> {:ok, spec} = Type.fetch_spec(String, :split, 1)
iex> inspect spec
"(String.t() -> [String.t()])"
Specs
retrieves a stored type from a module, and converts it to a Type.t/0
value.
Example:
iex> {:ok, type} = Type.fetch_type(String, :t)
iex> inspect type
"binary()"
If the type has non-zero arity, you can specify its passed parameters as the third argument.
Specs
resolves a remote type into its constitutent type. raises if the type is not found.
Specs
see Type.fetch_type/4
, except raises if the type is not found.
Specs
outputs the type which is guaranteed to satisfy the following conditions:
- if a term is in all of the types in the list, it is in the result type.
- if a term is not in any of the types in the list, it is not in the result type.
Example:
iex> import Type
iex> Type.intersection([builtin(:pos_integer), -1..10, -6..6])
1..6
Specs
outputs the type which is guaranteed to satisfy the following conditions:
- if a term is in both types, it is in the result type.
- if a term is not in either type, it is not in the result type.
Example:
iex> import Type
iex> Type.intersection(builtin(:non_neg_integer), -10..10)
0..10
guard that tests if the selected type is builtin
Example:
iex> Type.is_builtin(:foo)
false
iex> Type.is_builtin(%Type{name: :integer})
true
iex> Type.is_builtin(%Type{module: String, name: :t})
false
Note that composite builtin types may not match with this function:
iex> import Type
iex> Type.is_builtin(builtin(:mfa))
false
guard that tests if the selected type is remote
Example:
iex> Type.is_remote(:foo)
false
iex> Type.is_remote(%Type{name: :integer})
false
iex> Type.is_remote(%Type{module: String, name: :t})
true
Specs
returns the type of the term.
Examples:
iex> Type.of(47)
47
iex> inspect Type.of(47.0)
"float()"
iex> inspect Type.of([:foo, :bar])
"[:bar | :foo, ...]"
iex> inspect Type.of([:foo | :bar])
"nonempty_improper_list(:foo, :bar)"
Note that for functions, this may not be correct unless you
supply an inference engine (see Type.Function
):
iex> inspect Type.of(&(&1 + 1))
"(any() -> any())"
For maps, atom and number literals are marshalled into required terms; other literals, like strings, are marshalled into optional terms.
iex> inspect Type.of(%{foo: :bar})
"%{foo: :bar}"
iex> inspect Type.of(%{1 => :one})
"%{required(1) => :one}"
iex> inspect Type.of(%{"foo" => :bar, "baz" => "quux"})
"%{optional(String.t()) => :bar | String.t()}"
iex> inspect Type.of(1..10)
"%Range{first: 1, last: 10}"
Specs
helper macro to match on remote types
Example:
iex> Type.remote(String.t())
%Type{module: String, name: :t}
Specs
outputs whether one type is a subtype of itself. To be true, the following condition must be satisfied:
- if a term is in the first type, then it is also in the second type.
Note that any type is automatically a subtype of itself.
Examples:
iex> import Type
iex> Type.subtype?(10, 1..47)
true
iex> Type.subtype?(10, builtin(:integer))
true
iex> Type.subtype?(1..47, builtin(:integer))
true
iex> Type.subtype?(builtin(:integer), 1..47)
false
iex> Type.subtype?(1..47, 1..47)
true
Remote Types
Remote types are considered to be a signal that terms in the remote type
satisfy "special properties". For example, String.t/0
terms are not
only binaries, but are UTF-8 encoded binaries. Thus a remote type is
considered to be the subtype of its specification, but not vice versa:
iex> import Type
iex> binary = %Type.Bitstring{size: 0, unit: 8}
iex> Type.subtype?(remote(String.t()), binary)
true
iex> Type.subtype?(binary, remote(String.t()))
false
Specs
true if the passed term is an element of the type.
Important:
Note the argument order for this function, it does not have the
same call order, as say, JavaScript's instanceof
, or ruby's .is_a?
Example:
iex> import Type
iex> Type.type_match?(builtin(:integer), 10)
true
iex> Type.type_match?(builtin(:neg_integer), 10)
false
iex> Type.type_match?(builtin(:pos_integer), 10)
true
iex> Type.type_match?(1..9, 10)
false
iex> Type.type_match?(-47..47, 10)
true
iex> Type.type_match?(42, 10)
false
iex> Type.type_match?(10, 10)
true
Specs
The typegroup of the type.
This is a 'fastlane' value which simplifies generating type ordering code.
See Type.compare/2
for a list of which groups the types belong to.
NB: group assignments may change.
Specs
outputs the type which is guaranteed to satisfy the following conditions:
- if a term is in any of the types, it is in the result type.
- if a term is not in any type, it is not in the result type.
union/1
will try to collapse types into the simplest representation,
but the success of this operation is not guaranteed.
Example:
iex> import Type
iex> inspect Type.union([builtin(:pos_integer), -10..10, 32, builtin(:neg_integer)])
"integer()"
Specs
outputs the type which is guaranteed to satisfy the following conditions:
- if a term is in either type, it is in the result type.
- if a term is not in either type, it is not in the result type.
union/2
will try to collapse types into the simplest representation,
but the success of this operation is not guaranteed.
Example:
iex> import Type
iex> inspect Type.union(builtin(:non_neg_integer), -10..10)
"-10..-1 | non_neg_integer()"
Specs
Main utility function for determining type correctness.
Answers the question: If a system claims to require a certain "target type" to execute without crashing, what will happen if send a term that satisfies a "challenge type"?
The result may be one of:
:ok
, which signals that no crash will occur{:maybe, [messages]}
, which signals that a crash may occur due to one of the listed potential problems, but there are terms which will not trigger a crash.{:error, message}
which signals that all terms in the challenge type will trigger a crash.
These three levels are intended to roughly correspond to:
- "no notification to the user"
- "emit a warning using
IO.warn/2
" - "halt compilation with
CompileError
"
for a running compile-time analysis.
usable_as/3
also may be passed metadata which can be used to correctly
craft warning and error messages; as well as being filters for user-defined
exceptions to warning or error rules.
Relationship to subtype/2
at first glance, it would seem that the subtype?/2
function is
equivalent to usable_as/3
, but there are cases where the relationship
is not as clear. For example, if a function has the signature:
(integer() -> integer())
, that is not necessarily usable as a function
that is (any() -> integer())
, since it may be sent a value outside
of the integers. Conversely an (any() -> integer())
function IS
usable as an (integer() -> integer())
function. The subtyping
relationship between these function types is unclear; in the Mavis
system they are considered to be independent functions that are not
subtypes of each other.
Examples:
iex> import Type
iex> Type.usable_as(1, builtin(:integer))
:ok
iex> Type.usable_as(1, builtin(:neg_integer))
{:error, %Type.Message{type: 1, target: builtin(:neg_integer)}}
iex> Type.usable_as(-10..10, builtin(:neg_integer))
{:maybe, [%Type.Message{type: -10..10, target: builtin(:neg_integer)}]}
Remote types:
A remote type is intended to indicate that there is a quality outside of
the type system which specifies the type. Thus, a remote type should
be usable as the type it encapsulates, but it should emit a maybe
when
going the other direction:
iex> import Type
iex> binary = %Type.Bitstring{size: 0, unit: 8}
iex> Type.usable_as(remote(String.t()), binary)
:ok
iex> Type.usable_as(binary, remote(String.t))
{:maybe, [%Type.Message{
type: binary,
target: remote(String.t()),
meta: [message: """
binary() is an equivalent type to String.t() but it may fail because it is
a remote encapsulation which may require qualifications outside the type system.
"""]}]}