TypeCheck v0.2.0 TypeCheck View Source

Fast and flexible runtime type-checking.

The main way to use TypeCheck is by adding use TypeCheck in your modules. This will allow you to use the macros of TypeCheck.Macros in your module, which are versions of the normal type-specification module attributes with an extra explamation point at the end: @type!, @spec!, @typep! and @opaque!.

It will also bring all functions in TypeCheck.Builtin in scope, which is usually what you want as this allows you to use all types and special syntax that are built-in to Elixir in the TypeCheck specifications.

Using these, you're able to add function-specifications to your functions which will wrap them with runtime type-checks. You'll also be able to create your own type-specifications that can be used in other type- and function-specifications in the same or other modules later on:

defmodule User do
  use TypeCheck
  defstruct [:name, :age]
  @type! age :: non_neg_integer()
  @type! t :: %User{name: binary(), age: age()}

  @spec! new(binary(), age()) :: t()
  def new(name, age) do
    %User{name: name, age: age}
  end

  @spec! old_enough?(t(), age()) :: boolean()
  def old_enough?(user, limit) do
    user.age >= limit
  end
end

Types and their syntax

TypeCheck allows types written using (essentially) the same syntax as Elixir's builtin typespecs. This means the following:

  • literal values like :ok, 10.0 or "my string" desugar to a call to TypeCheck.Builtin.literal/1, which is a type that matches only exactly that value.
  • Basic types like integer(), float(), atom() etc. are directly supported (and exist as functions in TypeCheck.Builtin).
  • tuples of types like {atom(), integer()} are supported (and desugar to TypeCheck.Builtin.fixed_tuple/1)
  • maps where keys are literal values and the values are types like %{a: integer(), b: integer(), 42 => float()} desugar to calls to TypeCheck.Builtin.fixed_map/1.
    • The same happens with structs like %User{name: binary(), age: non_neg_integer()}
  • sum types like integer() | string() | atom() are supported, and desugar to calls to TypeCheck.Builtin.one_of/1.
  • Ranges like lower..higher are supported, matching integers within the given range. This desugars into a call to TypeCheck.Builtin.range/1.

Currently unsupported features

The following typespec syntax can not currently be used in TypeCheck. This will hopefully change in future versions of the library.

  • Binary pattern-matches containing size-references like <<_ :: size>>.
  • (Anonymous) function types like ( -> result_type) and (type1, type2 -> result_type).
  • Shorthand nonempty list syntax ([some_type, ...])
  • Literal maps with required(...) and optional(...) keys. (TypeCheck does already support literal maps with a fixed set of keys, as well as maps with any number of key-value-pairs of fixed types. It is the special syntax that might mix these approaches that is not supported yet.)

Extensions

TypeCheck adds the following extensions on Elixir's builtin typespec syntax:

  • fixed-size lists containing types like [1, 2, integer()] are supported, and desugar to TypeCheck.Builtin.fixed_list/1. This example matches only lists of 3 elements where the first element is the literal 1, the second the literal 2 and the last element any integer. Elixir's builtin typespecs do not support fixed-size lists.
  • named types like x :: integer() are supported; these are useful in combination with "type guards" (see the section below).
  • "type guards" using the syntax some_type when arbitrary_code are supported, to add extra arbitrary checks to a value for it to match the type. (See the section about type guards below.)
  • lazy(some_type), which defers type-expansion until during runtime. This is required to be able to expand recursive types. C.f. TypeCheck.Builtin.lazy/1

Named Types Type Guards

To add extra custom checks to a type, you can use a so-called 'type guard'. This is arbitrary code that is executed during a type-check once the type itself already matches.

You can use "named types" to refer to (parts of) the value that matched the type, and refer to these from a type-guard:

type sorted_pair :: {lower :: number(), higher :: number()} when lower <= higher
iex> TypeCheck.conforms!({10, 20}, sorted_pair)
{10, 20}
iex> TypeCheck.conforms!({20, 10}, sorted_pair)
** (TypeCheck.TypeError) `{20, 10}` does not check against `({lower :: number(), higher :: number()} when lower <= higher)`. Reason:
  type guard:
    `lower <= higher` evaluated to false or nil.
    bound values: %{higher: 10, lower: 20}

Named types are available in your guard even from the (both local and remote) types that you are using in your time, as long as those types are not defined as opaque types.

Manual type-checking

If you want to check values against a type outside of the checks the @spec! macro wraps a function with, you can use the conforms/2/conforms?/2/conforms!/2 macros in this module directly in your code.

These are evaluated at compile time which means the resulting checks will be optimized by the compiler. Unfortunately it also means that the types passed to them have to be known at compile time.

If you have a type that is constructed dynamically at runtime, you can resort to dynamic_conforms/2 and variants. Because these variants have to evaluate the type-checking code at runtime, these checks are not optimized by the compiler.

Link to this section Summary

Functions

Makes sure value typechecks the type description type.

Similar to conforms/2, but returns value if the value typechecked and raises TypeCheck.TypeError if it did not.

Similar to conforms/2, but returns true if the value typechecked and false if it did not.

Makes sure value typechecks the type type. Evaluated at runtime.

Similar to dynamic_conforms/2, but returns value if the value typechecked and raises TypeCheck.TypeError if it did not.

Similar to dynamic_conforms/2, but returns true if the value typechecked and false if it did not.

Link to this section Types

Specs

value() :: any()

Link to this section Functions

Link to this macro

conforms(value, type)

View Source (macro)

Specs

conforms(value(), TypeCheck.Type.expandable_type()) ::
  {:ok, value()} | {:error, TypeCheck.TypeError.t()}

Makes sure value typechecks the type description type.

If it typechecks, we return {:ok, value} Otherwise, we return {:error, %TypeCheck.TypeError{}} which contains information about why the value failed the check.

conforms is a macro and expands the type check at compile-time, allowing it to be optimized by the compiler.

C.f. TypeCheck.Type.build/1 for more information on what type-expressions are allowed as type parameter.

Note: usually you'll want to import TypeCheck.Builtin in the context where you use conforms, which will bring Elixir's builtin types into scope. (Calling use TypeCheck will already do this; see the module documentation of TypeCheck for more information))

Link to this macro

conforms!(value, type)

View Source (macro)

Specs

Similar to conforms/2, but returns value if the value typechecked and raises TypeCheck.TypeError if it did not.

The same features and restrictions apply to this function as to conforms/2.

Link to this macro

conforms?(value, type)

View Source (macro)

Specs

Similar to conforms/2, but returns true if the value typechecked and false if it did not.

The same features and restrictions apply to this function as to conforms/2.

Link to this function

dynamic_conforms(value, type)

View Source

Specs

dynamic_conforms(value(), TypeCheck.Type.t()) ::
  {:ok, value()} | {:error, TypeCheck.TypeError.t()}

Makes sure value typechecks the type type. Evaluated at runtime.

Because dynamic_conforms/2 is evaluated at runtime:

  1. The typecheck cannot be optimized by the compiler, which makes it slower.

  2. You must pass an already-expanded type as type. This can be done by using one of your custom types directly (e.g. YourModule.typename()), or by calling TypeCheck.Type.build.

Use dynamic_conforms only when you cannot use the normal conforms/2, for instance when you're only able to construct the type to check against at runtime.

Link to this function

dynamic_conforms!(value, type)

View Source

Specs

dynamic_conforms!(value(), TypeCheck.Type.t()) :: value() | no_return()

Similar to dynamic_conforms/2, but returns value if the value typechecked and raises TypeCheck.TypeError if it did not.

The same features and restrictions apply to this function as to dynamic_conforms/2.

Link to this function

dynamic_conforms?(value, type)

View Source

Specs

dynamic_conforms?(value(), TypeCheck.Type.t()) :: boolean()

Similar to dynamic_conforms/2, but returns true if the value typechecked and false if it did not.

The same features and restrictions apply to this function as to dynamic_conforms/2.