Joseph Kain bio photo

Joseph Kain

Professional Software Engineer learning Elixir.

Twitter LinkedIn Github

Last week I wrote a post about setting up Dialyxir and analyzing type safety in Elixir. This week I continue the effort and add type specs to an existing Elixi source base - my Game of Life in Elixr.

Elixir @spec

I started by adding an @spec to the function count_live_neighbors/3 as it seemed straighforward:

@spec count_live_neighbors(Integer, Integer, any) :: Integer
defp count_live_neighbors(x, y, board) do
  list_of_neighbors(x, y, board)
  |> Enum.map(&state_as_int/1)
  |> Enum.sum
end

I didn’t get the spec right but Dialyzer’s error message was helpful:

life.ex:45: Invalid type specification for function 'Elixir.Life':count_live_neighbors/3. The success typing is (_,_,_) -> number()

I rewrote the spec as:

@spec count_live_neighbors(Integer, Integer, any) :: number

Thinking about this more, I could have also looked up the return type of Enum.sum:

@spec sum(t) :: number
sum(collection)

But, what if I wanted the result to be Integer? The number of live neighbors is an integer whereas number represents integer or float (see http://elixir-lang.org/docs/v1.0/elixir/Kernel.Typespec.html). How can I coerce the result after Sum? The best I’ve been able to do is:

@spec count_live_neighbors(Integer, Integer, any) :: integer()
defp count_live_neighbors(x, y, board) do
  list_of_neighbors(x, y, board)
  |> Enum.map(&state_as_int/1)
  |> Enum.sum
  |> round
end

A type for my board

Next I wanted to create a type for the game Board which should be defined in module Board. I started with

@type board :: map

and put it to use in module Life

@spec board_to_string(Board.board) :: String.t
def board_to_string(board), do: Board.to_string(board)

But later while reading over core Elixir types I realized that a more idiomatic name for the board type would be Board.t which is defined like this:

defmodule Life.Board do
  @type t :: map
  # ...
end

and then refered to as just t within the Board module and as Board.t outside. For a while I experimented with refering the the type as Board.t inside the module. This seems a little clearer to me but doesn’t match the patter used in the Elixir standard library. But, be aware that some of the error messages from Dizalyer may show Board.t.

In Elixr, for module Foo the main type for Foo should be named Foo.t

Next I looked at the function Board.apply_rules/3

Builtin types

defp apply_rules({x, y}, value, board) do
  case {value, count_live_neighbors(x, y, board)} do
    {v, c} when (v == @live) and (c < 2)            -> @dead
    {v, c} when (v == @live) and (c == 2 or c == 3) -> @live
    {v, c} when (v == @live) and (c > 3)            -> @dead
    {v, c} when (v == @dead) and (c == 3)           -> @live
    {v, _}                                          -> v
  end
end

I’m was not sure what the type of value should be. It is supposed to be a printable character. So, is it Integer? I was hoping to enter sometihng invalid and have Dialyzer tell me the success typing. But it seems to accept anything here. I tried:

@spec apply_rules({Integer, Integer}, Integer, Board.board) :: Integer

And it seems to work. But, looking through the documentation for Kernel.Typespec I see there is a buitin type char(). I changed the code to use the following spec:

@spec apply_rules({Integer, Integer}, char(), Board.t) :: Integer

Digging into Dialyzer broken contracts

The most difficult specs to write revolved aroun my Board constructors:

defmodule Life.Board do

  @type board :: map

  @spec new_from_file(Path.t) :: board
  def new_from_file(seedfile) do
    {:ok, seed} = File.read(seedfile)
    new_from_string(seed)
  end

  @spec new_from_string(String.t) :: board
  def new_from_string(string) do
    Map.new |> insert(0, 0, String.to_char_list(string))
  end

  @spec insert(t, Integer, Integer, char_list) :: t
  defp insert(map, _x, _y, []), do: map
  defp insert(map, _x,  y, [10 | list]), do: insert(map, 0, y + 1, list)
  defp insert(map,  x,  y, [char | list]) do
    new_map = Map.put(map, {x, y}, char)
    insert(new_map, x + 1, y, list)
  end

Adding the type specs to new_from_file and new_from_string worked but insert broke the contracts.

Starting Dialyzer
dialyzer --no_check_plt --plt /Users/jkain/.dialyxir_core_17_1.0.4.plt -Wunmatched_returns -Werror_handling -Wrace_conditions -Wunderspecs /Users/jkain/Documents/Projects/elixir/life/_build/dev/lib/life/ebin
  Proceeding with analysis...
board.ex:6: Function new_from_file/1 has no local return
board.ex:12: Function new_from_string/1 has no local return
board.ex:13: The call 'Elixir.Life.Board':insert(#{},0,0,string()) breaks the contract ('Elixir.Board':t(),'Elixir.Integer','Elixir.Integer',elixir:char_list()) -> 'Elixir.Board':t()
board.ex:16: Invalid type specification for function 'Elixir.Life.Board':insert/4. The success typing is (#{},non_neg_integer(),non_neg_integer(),string()) -> #{}
board.ex:18: The call 'Elixir.Life.Board':insert(map@1::#{},0,pos_integer(),list@1::string()) breaks the contract ('Elixir.Board':t(),'Elixir.Integer','Elixir.Integer',elixir:char_list()) -> 'Elixir.Board':t()
board.ex:21: The call 'Elixir.Life.Board':insert(new_map@1::#{},pos_integer(),y@1::non_neg_integer(),list@1::string()) breaks the contract ('Elixir.Board':t(),'Elixir.Integer','Elixir.Integer',elixir:char_list()) -> 'Elixir.Board':t()
cli.ex:6: Function main/0 has no local return
cli.ex:6: Function main/1 has no local return
life.ex:7: Function run_game/2 has no local return
life.ex:7: Function run_game/1 has no local return
life.ex:14: Function seed_board/1 has no local return
life.ex:15: Function do_seed_board/1 has no local return
life.ex:18: Function empty_board/0 has no local return
life.ex:20: Function iterations/1 will never be called
life.ex:21: Function 'animated?'/1 will never be called
life.ex:23: Function run_loop/3 will never be called
life.ex:32: Function tick/1 will never be called
life.ex:33: The created fun has no local return
life.ex:36: Function apply_rules/3 will never be called
life.ex:47: Function count_live_neighbors/3 will never be called
life.ex:54: Function list_of_neighbors/3 will never be called
life.ex:61: Function output/2 will never be called
life.ex:61: Function output/3 will never be called
profile.ex:22: The created fun has no local return
profile.ex:24: Function run_test/0 has no local return
Unknown functions:
  'Elixir.ExProf':analyze/0
  'Elixir.ExProf':start/1
  'Elixir.ExProf':stop/0
  'Elixir.Statistics':mean/1
  eflame:apply/2
  fprof:analyse/1
  fprof:apply/2
  fprof:profile/0
Unknown types:
  'Elixir.Board':t/0
 done in 0m0.88s
done (warnings were emitted)

The first thought I had is that this error

board.ex:13: The call 'Elixir.Life.Board':insert(#{},0,0,string()) breaks the contract ('Elixir.Board':t(),'Elixir.Integer','Elixir.Integer',elixir:char_list()) -> 'Elixir.Board':t()

was caused by

@spec new_from_string(String.t) :: Board.t
def new_from_string(string) do
  Map.new |> insert(0, 0, String.to_char_list(string))
end

because Map.new returns a map and I pass that map into insert/4 rather than a Board.t. But I get the same errors even if I rewrite the code as:

@spec new_from_string(String.t) :: t
def new_from_string(string) do
  empty_board |> insert(0, 0, String.to_char_list(string))
end

@spec empty_board :: t
defp empty_board do
  Map.new
end

I’ve read through some of the type specs in the Elixir standard library sources but I wasn’t ab\le to find any examples that helped. I’ve tried other things a bit haphazardly and I wanted to try a more systematic approach. I figured that I should be able to build a very general type spec that I would pass and then make it increasingly more specific until I encountered the error. This would allow me to idenitfy which types are the problem:

Board.insert/4 Type spec Result
no type spec Dialyzer runs clean
@spec insert(any, any, any, any) :: map supertype warning
@spec insert(t, any, any, any) :: t supertype warning
@spec insert(t, Integer, any, any) :: t Broken contract!
@spec insert(t, non_neg_integer(), any, any) :: t supertype warning
@spec insert(t, integer(), integer(), string()) :: t supertype warning

Where “supertype warning” means something like this:

board.ex:22: Type specification 'Elixir.Life.Board':insert(t(),non_neg_integer(),any(),any()) -> t() is a supertype of the success typing: 'Elixir.Life.Board':insert(#{},non_neg_integer(),non_neg_integer(),string()) -> #{}

and “Broken contract!” means:

board.ex:5: The specification for 'Elixir.Life.Board':new_from_file/1 states that the function might also return #{} but the inferred return is none()
board.ex:6: Function new_from_file/1 has no local return
board.ex:11: The specification for 'Elixir.Life.Board':new_from_string/1 states that the function might also return #{} but the inferred return is none()
board.ex:12: Function new_from_string/1 has no local return
board.ex:13: The call 'Elixir.Life.Board':insert(#{},0,0,string()) breaks the contract (t(),'Elixir.Integer',any(),any()) -> t()
board.ex:22: Invalid type specification for function 'Elixir.Life.Board':insert/4. The success typing is (#{},non_neg_integer(),non_neg_integer(),string()) -> #{}
board.ex:24: The call 'Elixir.Life.Board':insert(map@1::#{},0,pos_integer(),list@1::string()) breaks the contract (t(),'Elixir.Integer',any(),any()) -> t()
board.ex:27: The call 'Elixir.Life.Board':insert(new_map@1::#{},pos_integer(),y@1::non_neg_integer(),list@1::string()) breaks the contract (t(),'Elixir.Integer',any(),any()) -> t()

Futhermore, if I use the following typespec then Dialyzer runs clean:

@spec insert(t, non_neg_integer(), non_neg_integer(), char_list) :: t

But it seems wrong to me. I prefer

@spec insert(t, integer(), integer(), string()) :: t

even though Dialyzer issues the supertype warning. The reason for this is that Dialyzer seems to use the values 0, 0 passed in this call

@spec new_from_string(String.t) :: t
def new_from_string(string) do
  Map.new |> insert(0, 0, String.to_char_list(string))
end

to decide that the type should be non_neg_integer() instead of integer(). If I, for example, change the code to

@spec new_from_string(String.t) :: t
def new_from_string(string) do
  Map.new |> insert(-1, -1, String.to_char_list(string))
end

Then the success typing changes. I find this a little bit confusing - it seems a little strange to me that usage impilies typing. To understand this better I read parts of the original Dialyzer paper Success Typing. Section 6.2 “Module system to the rescue” describes that unexported (private) functions do use information from calls to refine the function’s domain. Section 6.3 goes on to describe that data flow analysis also informs the type inference. So in this case the usage does imply non_neg_integer(). But, I will use integer() in Board.insert/4’s type spec as integer() matches my intention for the function.

In Dialyzer, usage affects the typing of private functions.

Another thing that confused me is that integer() is compatible with the success typing for Board.insert/4 but Integer is not. According to Kernel.TypeSpecs Integer is

Integer :: integer
         | ElixirInteger                # ..., -1, 0, 1, ... 42 ...
         | ElixirInteger..ElixirInteger # an integer range

so Integer should be a supertype of integer() as well as non_neg_integer() (by the transitive property of supertypes). For quite some time I didn’t understand the problem here. But eventually I realized that Integer is too wide of a type. The fact that it is a supertype of integer() is the problem. The reason being this head of insert function:

defp insert(map, _x,  y, [10 | list]), do: insert(map, 0, y + 1, list)

Kernel +value has type

+number :: number

That is, addition only works on number() and not Integer. Integer includes integer ranges and addition doesn’t support ranges.

Conclusion

I’ve learned a lot about Elixir type specs while writing this post. I’ve only written six specs but have run into a few intriguing issues with Dialyzer. I think there is still a lot to learn about Dialyzer and more practice to had before I can be successful using it. But, I do belive that the type checking will be worth the effort. Also, I found that adding the type annotations after the fact was tedious - in the future I plan to add them as I write the code.