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

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!

Made with Slides.com