Closest thing to static type checking?

I’m reading through the type annotations features and trying to figure out how to enforce type checking in a Phoenix app. I’m pretty sure it has something to do with the dialyzer. :wink:

Given a project with functions like this, how can I type check it before releasing? The VS Code extension gives me convenient yellow squigglies when my annotations don’t map the derived types. It’s a start, but I want it to be more strict.

  @type presence_entry :: {any(), %{metas: list(%{ atom() => any() })}}
  @spec presence_by_region(list(presence_entry)) :: %{any() =>  non_neg_integer()}
  def presence_by_region(presence) do
    result = presence
              |> Enum.map(&(elem(&1,1)))
              |> Enum.flat_map(&Map.get(&1, :metas))
              |> Enum.filter(&Map.has_key?(&1, :region))
              |> Enum.group_by(&Map.get(&1, :region))
              |> Enum.sort_by(&(elem(&1, 0)))
              |> Map.new(fn {k,v}-> {k, length(v) } end)

    result
  end

The thing to keep in mind is that fundamentally you can’t. Elixir is not typed, if you come from another typed language you should let go of that expectation or you will be disappointed.

HOWEVER, there are tools to help solve similar problems. Dialyzer is one, but it is not a static type checker, it does success typing. It’s important to know the difference, its limitations and what it aims to do.

Similarly, there are libraries like Norm and Hammox that can give you extra safety if you need it but again they approach the problem differently.

Finally if you want a statically typed language on the BEAM check out Gleam or Caramel.

3 Likes

This is actually Dialyzer. So what you get with the VSCode extension is pretty much as far as Dialyzer can go.

1 Like

I’m fine with a build step. :smiley: I mean, I’d love a full static type system but annotations + static analysis seem like they could be enough.

The general starting point would be to add dialyxir to your project:

That will give you a mix dialyzer task that you can use for CI to fail the build. Although if you don’t start this at the beginning of a project it can be quite difficult to add later because it can be very difficult to understand all the dialyzer errors, dialyzer is rather hamstrung since it has to rely on success typing.

2 Likes

If you are okay with runtime checks with meaningful error messages then using Domo, TypedStruct (which Domo uses) and EnumType have worked really well for me.

As for real compile-time static typing, so far there’s no such thing in the BEAM ecosystem although Facebook has announced that they are working on it.

1 Like

Adding to the runtime options, I have been happy using TypeCheck (TypeCheck: Fast and flexible runtime type-checking for your Elixir projects. — TypeCheck v0.3.2). The specs are written nearly the same as regular typespecs, so it feels familiar.

Beyond that, heavy use of function head pattern-matching and guards can get you a long way in enforcing correctness.

2 Likes

Except Gleam and Caramel, right?

I’ve taken a bit of a break from it (life is busy since I’m moving cities and I am architecting code at work) but I’m working on a compile-time static type checker for elixir. It should be more powerful/useful than dialyzer since it takes some stances on what types mean and it will have type literals and subtraction types.

It’s nowhere near there yet though, ha.

6 Likes

So I hear, never tried them still though.

I think maybe the spec could be:

# I am not sure what that first item is in the tuple but if it is an id I might
# make a type like: @type id() :: non_neg_integer() | String.t() assuming that
# maybe the id could be either of those types then I would replace the any() with
# id() in the presence_entry() type. 
@type presence_entry() :: {any(), Phoenix.Presence.presence()}

# I am going to make a new type here for the region, but I am making assumptions as I don't know the rest of the code and the type could be something else.
@type region() :: String.t()

@spec presence_by_region([presence_entry()]) :: %{region() => non_neg_integer()}

Dialyzer always assumes the most generic types possible, so since the code provided does not explicitly call anything on the any() it just assumes this can be anything, as the function will work if you pass anything into it for that parameter. I have found over the years that the more explicit you can be in telling Diazlyer your intentions the better it will type check across the codebase. That is why I recommend making a new type for the any()s - region() and id().

While Dialyzer isn’t very strict by default you can try to add a few flags to your Dialyzer configuration and just be explicit as possible. Plus I have found that being super explicit in types helps provide documentation on how the code is intended to work.

Another thing I might consider with this code is turning the presence entry into a struct and providing some function to make a Phoenix presence into this more known data structure (please note I am making a few assumptions here around naming, field, and types).

defmodule PresenceUser do

  @type id() :: non_neg_integer() | String.t()

  @type region() :: String.t()

  @type t() :: %__MODULE__{
          id: id(),
          region: region() | nil
        }

  defstruct id: nil, region: nil

  @spec from_presence_entry(presence_entry()) :: t()
  def from_presence_entry({id, presence}) do
    region = get_in(presence, [:metas, :region])
    %__MODULE__{id: id, region: region}
  end
end

## then the new spec for presence_by_region/1

@spec presence_by_region([PresenceUser.t()]) :: %{PresenceUser.region() => non_neg_integer()}

The exact names and types might be different depending on your codebase. I might even move the from_presence_entry/1 up a level in the API.

The reason I would do something like this is that not only is the code explicit to the reader, which is nice, it tells Dialyzer a lot of information, which as the codebase grows will help make Dialyzer seem more strict. However, I know that the community has various degrees of opinions on structs and Dialyzer. While this way of writing typed Elixir requires more typing I found it very useful over the years.

Here’s an example of the Dialyzer flags we set on most of our projects: vintage_net_mobile/mix.exs at main · nerves-networking/vintage_net_mobile · GitHub

2 Likes