W

Gradual Types In Elixir

August 21, 2018

Elixir isn’t a statically typed language but with structs, schemas, and pattern matching along with a robust, built-in test suite you have a toolbox that allows for gradual typing.

To illustrate gradual typing we’ll walk through an example of creating a user login with the parameters username, email_address and age. For this example, we’ll print a message with parameters: username: #{params["username"]}, email_address: #{params["email_address"]}, next_year_age: #{next_year_age} where next_year_age is age plus one.

With Just Params

If we were using the Phoenix Framework, our controller request would consists of a map with strings as keys: %{"username" => "waldo", "email_address" => "waldo@where.com", "age" => 12}. To print our message, we would need to add 1 to age and parse each property from the map.

  defmodule Login do

    def print(params) do
      next_year_age = params["age"] + 1

      "username: #{params["username"]}, email_address: #{params["email_address"]}, next_year_age: #{next_year_age}"
    end
  end

Here we just pass in the map as params. The advantage of this method is that it’s fast and readable. If our login payload changed there are a couple potential issues that a statically typed languages would catch at compile time. First, we can pass any type (a map, a string, an integer) into print as params so there is no contractual guarantee that print would receive a map with the properties "username", "email_address", or "age. We also have no guarantee that params["age"] is going to be a number. If "age" is passed in as a string we’ll run into an ArithmeticError at runtime.

Check here.

With a Login Struct

Elixir has structs which gives your some more structure. Let’s define a Login struct and see how we can better our code.

defmodule Login do

  defstruct username: nil,  age: nil, email_address: nil

  @spec cast_login(map()) :: %Login{}
  def cast_login(params) when is_map(params) do
    %Login{username: params["username"], age: params["age"], email_address: params["email_address"]}
  end

  def print(%Login{} = login) do
    next_year_age = login.age + 1

    "username: #{login.username}, email_address: #{login.email_address}, next_year_age: #{
      next_year_age
    }"
  end

  ...

end

Here we define a Login struct with the fields username, age and email_address and use cast_login to cast the params to a Login struct. Although we still don’t have any guarantees about Login's properties’ types we get the guarantee that those properties are a part of Login. The biggest benefit of defining Login is that we can guarantee that print will only run if it receives the Login struct.

To help with the issue where any type can be passed into cast_login, we introduce when along with is_map which ensures that cast_login will run only if it receives a map. There are still no guarantees that we will receive the username, email_address and age properties but at least it prevents a random string from being passed in. If you try to pass in a string like "waldo", you’ll get a FunctionClauseError at runtime which looks like this in testing.

  1) test print login with valid login (GradualTypesTest02)
     test/02_test.exs:5
     ** (FunctionClauseError) no function clause matching in Login.cast_login/1

     The following arguments were given to Login.cast_login/1:

         # 1
         "waldo"

     Attempted function clauses (showing 1 out of 1):

         def cast_login(params) when is_map(params)

     code: login = Login.cast_login("waldo")
     stacktrace:
       (gradual_types) lib/login.ex:8: Login.cast_login/1
       test/02_test.exs:7: (test)

Code here

With BetterLogin Ecto.Schema

Ecto is an Elixir dependency that facilitates database interaction. When coming from other ORMs in other frameworks, it took me a while to get my head around Ecto which take a more functional approach. One characteristic of Ecto that sets it apart from other ORMs is that it’s components are loosely coupled. As a result, we can use Ecto.Schema to create “types” and help us cast our maps to them.

defmodule BetterLogin do
  @moduledoc """
  Better login module
  """
  
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field(:username, :string)
    field(:email_address, :string)
    field(:age, :integer)
  end

  def marshal(params) do
    %BetterLogin{}
    |> cast(params, [:username, :email_address, :age])
    |> apply_changes
  end

  @spec print(%BetterLogin{}) :: String.t
  def print(%BetterLogin{} = login) do
    next_year_age = login.age + 1

    "username: #{login.username}, email_address: #{login.email_address}, next_year_age: #{
      next_year_age
    }"
  end
end

Here we introduce Ecto.Schema which is an additional dependency and not a part of the core of Elixir. Ecto.Schema is used to interact with your datastore but you can also use it as a type by using embedded_schema rather than schema when declaring the schema. Using Ecto also gives you access to Ecto.Changeset which allows you to cast and validate your map to the schema.

Code here

Conclusion

Elixir is often dismissed out of hand for larger projects because it isn’t statically typed. However, it does have built in features that provide you with many of the guarantees you get in a statically typed language and help tame large projects. You rarely get the best of both worlds but with Elixir you get pretty close to getting the benefits of dynamic code while getting the guarantees of a statically typed language.

Send comments, corrections to waynechoi@gmail.com