Send comments, corrections to waynechoi@gmail.com
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.