Effortless Runtime Type-Checking
in Elixir

Wiebe-Marten Wijnja

Outline

  • What and why?
    • Type-Checking?
    • Runtime Type-Checking?
    • Effortless Runtime Type-Checking?
  • How to use TypeCheck?
    • Basic Usage
    • 'Behind the scenes'
    • Advanced features
  • The future

What is type checking?

What is a type?

→ a set of possible values

What is type checking?

→ Is value one of the allowed possibilities?

Thinking of types depends a lot on language

Dynamically-typed

Statically-typed

Dynamically-typed

  • Values can be of many types
  • Type known at the very last moment

Statically-typed

Dynamically-typed

  • Values can be of many types
  • Type known at the very last moment

Statically-typed

def myfun(a, b) do
  # ... more code here
  c = a - b # ???
  # ... more code here
end

Dynamically-typed

  • Values can be of many types
  • Type known at the very last moment

Statically-typed

def myfun(a, b) do
  # ... more code here
  c = a - b # ???
  # ... more code here
end
myfun(10, 20)
myfun(10, "abcd")

Dynamically-typed

  • Values can be of many types
  • Type known at the very last moment

Statically-typed

  • Values have single type
  • Type known beforehand
    • (explicitly specified)
def myfun(a, b) do
  # ... more code here
  c = a - b # ???
  # ... more code here
end
myfun(10, 20)
myfun(10, "abcd")

Dynamically-typed

  • Values can be of many types
  • Type known at the very last moment

Statically-typed

  • Values have single type
  • Type known beforehand
    • (explicitly specified)
def myfun(a, b) do
  # ... more code here
  c = a - b # ???
  # ... more code here
end
fn myfun(a: Int, b: Int) -> Int {
  // ... more code here
  c = a - b // <- Known to be fine
  // ... more code here
}
myfun(10, 20)
myfun(10, "abcd")

Dynamically-typed

  • Values can be of many types
  • Type known at the very last moment

Statically-typed

  • Values have single type
  • Type known beforehand
    • (explicitly specified)
def myfun(a, b) do
  # ... more code here
  c = a - b # ???
  # ... more code here
end
fn myfun(a: Int, b: Int) -> Int {
  // ... more code here
  c = a - b // <- Known to be fine
  // ... more code here
}
myfun(10, 20)
myfun(10, "abcd")

Find problems earlier...

... by being more explicit

Dynamically-typed

Statically-typed

It Depends

root cause

 

To Type or Not to Type? | Bogner & Merkel

Dynamically-typed

Statically-typed

Dynamically-typed

Statically-typed

Elixir (and Erlang)

  • Dynamically Typed
  • ... but also:
    • Typespecs
    • Dialyzer

Typespecs

  • Pros:
    • Extra clarity for input & output of functions
    • Behaviours + callbacks
  • Cons:
    • On its own, documentation only
    • Some common types not supported!
      • string constants -> maps with string keys!
      • Protocols
      • Custom invariants?
@spec is_even(integer()) :: boolean()

Dialyzer

  • Pros:
    • Checks (using the typespecs) for common mistakes
    • Runs statically
  • Cons:
    • Misses common mistakes (false negatives)
    • Complains about some OK code (false positives)
    • Slow to run, difficult to understand errors
    • → Opt-in

Gradualizer: Promising, but experimental

Alternative approach?

  • Type system has limitations
  • Errors are unclear
  • Not everything can be checked statically

The Contract of a Function

The Contract of a Function

  • Property of the algorithm
    • Visibility varies by implementation
  • Breaking contract → bug

Clarity

defmodule Foo do
  def bar(a, b) do
    # ...
  end
end
defmodule Users.Highscore do
  def top10(a, b) do
    # ...
  end
end
defmodule Users.Highscore do
  def top10(users, scoring_fun) do
    # ...
  end
end
defmodule Users.Highscore do
  @spec top10(list(User.t()), (User.t() -> float())) :: list(User.t())
  def top10(users, scoring_fun) do
    # ...
  end
end
defmodule Users.Highscore do
  @spec top10(list(User.t()), (User.t() -> float())) :: list(User.t())
  def top10(users, scoring_fun) 
      when is_list(users) and is_function(scoring_fun, 1) do
    # ...
  end
end
defmodule Users.Highscore do
  @spec top10(list(User.t()), (User.t() -> float())) :: list(User.t())
  def top10(users, scoring_fun)
      when is_function(scoring_fun, 1) do
    ensure_list_of_users!(users)
    # ...
  end
  
  defp ensure_list_of_users!(users) do
    Enum.each(users, fn element ->
      case element do
        %User{} -> :ok
        other -> raise("Error, #{inspect(other)} is not a user.")
    end)
  end
end
defmodule Users.Highscore do
  @spec top10(list(User.t()), (User.t() -> float())) :: list(User.t())
  def top10(users, scoring_fun)
      when is_function(scoring_fun, 1) do
    ensure_list_of_users!(users)
    # ...
  end
  
  defp ensure_list_of_users!(users) do
    Enum.each(users, fn element ->
      case element do
        %User{} -> :ok
        other -> raise("Error, #{inspect(other)} is not a user.")
    end)
  end
end
  • Diaspora of error formats
  • More difficult checks not done at all
  • Duplication! (in different syntax!!)
defmodule Users.Highscore do
  use TypeCheck
  
  @spec! top10(list(User.t()), (User.t() -> float())) :: list(User.t())
  def top10(users, scoring_fun) do
    # ...
  end
end

→ Generate checks from typespecs

    → Same typespec still usable for Dialyzer

→ Errors are standardized

→ Difficult checks also happen!

 

'Effortless'

use TypeCheck:

defmodule Users.Highscore do
  use TypeCheck
  
  @spec! top10(list(User.t()), (User.t() -> float())) :: list(User.t())
  def top10(users, scoring_fun) do
    # ...
  end
end
defmodule Users.Highscore do
  use TypeCheck
  
  @spec top10(list(User.t()), (User.t() -> float())) :: list(User.t())
  def top10(users, scoring_fun) do
    # ...
  end
  
  defoverridable top10: 2
  def top10(users, scoring_fun) do
    TypeCheck.conforms!(users, list(User.t))
    TypeCheck.conforms!(scoring_fun, (User.t() -> float()))
    
    result = super(users, scoring_fun)
    
    TypeCheck.conforms!(result, list(User.t()))
    result
  end
  
  # Used for reflection & spectests
   def __TypeCheck_spec_for_'top10/2'__ do
   %TypeCheck.Builtin.Spec{
     #...
   }
  end
end

NOTE: actual checks

  • compiled inline
  • keep track of parameter index

How readable are the error messages?

Example of error messages

Example of error messages

Example of error messages

Example of error messages

Example of error messages

Type extensions

  • ranges for floats
  • fixed-size lists
  • string constants
    • maps with string constant keys
  • impl(ProtocolName)

 

  • 'Type guards' to add custom checks

Need more detailed types?

Is it fast enough?

  • Yes: It grows with the size of your input
  • And you can always turn it off in certain environments
    • (per env, per OTP app, per module, per function)

OTP 25: Type-based optimizations in the JIT

https://www.erlang.org/blog/type-based-optimizations-in-the-jit/

Advanced Features

  • Manual use of
     

  • Custom formatters
  • Overrides for external types
  • defstruct!
  • Spectests
TypeCheck.conforms! / TypeCheck.conforms? / TypeCheck.conforms

Spectests

  • Effortless property-based testing
  • Test against any valid input automatically

Higher Confidence

Spectests

defmodule Rating do
  use TypeCheck
  
  defstruct [:value, :author]
  @type! t() :: %Rating{value: 1..5, author: String.t()}
  
  @spec! average(list(t())) :: number()
  def average(ratings) do
    values = Enum.map(ratings, &(&1.value))
    Enum.sum(values) / Enum.count(values)
  end
end

Spectests

defmodule RatingTest do
  use ExUnit.Case, async: true
  use TypeCheck.ExUnit

  spectest Rating
  
  # ... other tests :-)
end

Spectests

If spectests are not enough,

write your own property-based tests

→ Types are usable as generators

How resilient is TypeCheck itself?

Dogfooding

Type definitions

Type-checking macros

Dogfooding

  • TypeCheck is thorougly (spec)tested
  • Metatypes
  • When using TypeCheck, same checks performed
    • At compile time?!

Future Plans

  • Stable release (nearly there!)
  • Companion libraries
    • type overrides for common libs
      (Phoenix, Ecto, Jason, Decimal, Absinthe, etc.)
  • Improve efficiency
  • Support PropEr as well (as StreamData)

Help greatly appreciated!

Conclusion

  • Type checks improve code resilience + clarity
  • TypeCheck: runtime type-checking to Elixir
    • Simple to get started, effortless to use

Out now!

TypeCheck - Effortless Type-Checking in Elixir

By qqwy

TypeCheck - Effortless Type-Checking in Elixir

TypeCheck is an Elixir library that takes your existing Elixir types + specs and builds runtime type-checks, clear error messages, improved documentation and automated property-based ‘spectests’ on top of them! In this talk, Marten will explain how simple it is to use TypeCheck in your projects and why and when you’d want to. Also, he will give a small peek behind the curtain of the extensive not-so-simple metaprogramming required to make all of this happen ;-). https://github.com/Qqwy/elixir-type_check

  • 664