Using Maps in Typespecs

Summary: We look at some examples of how to typespec maps that are passed as arguments to your functions. We assume you're loosely familiar with Elixir typespec syntax and the Dialyzer/Dialyxir tool.

On this week's episode of ElixirTalk, we made a mistake during our discussion of typespecs that I'd like to address and correct. We erroneously suggested that, when typespecing maps in function calls, you could only specify the type of the key, not the value. That's totally backwards! You can absolutely specify the types of the values in your map, along with the keys, and that's really important for identifying valid function parameters.

Let's look at some examples.

@spec foo(map()) :: nil

The most generic. Can be called with any map, regardless of what keys are present. Empty maps are also legal.

@spec foo(%{}) :: nil

Must be called with an empty map.

@spec foo(%{required(:email) => String.t()}) :: nil

Expects a one-element map. The single key must be the atom :email and its value must be a string. This can also be written as:

@spec foo(%{email: String.t()}) :: nil

This syntax is useful shorthand when your map contains only atom keys that are all required. You cannot mix optional and required keys with this syntax.

@spec foo(%{required(:email) => String.t(), required(:age) => integer()}) :: nil

Expects a two-element map. One key must be the atom :email, which points to a string, and the other key must be the atom :age, which points to an integer.

@spec foo(%{required(:email) => String.t(), optional(:age) => integer()}) :: nil

Similar to before, except the map doesn't have to contain the :age key. :email must still be present.

@spec foo(%{required(:email) => String.t(), optional(:age) => integer(), optional(any()) => any()}) :: nil

Keys aside from :email and :age are allowed in the map. Useful for when your function accepts a large map, such as params, but only does work on a few of the elements.

Spring Evening in Venice, California
Sunset from the Crevalle offices in Venice, California

What if I have keys besides atoms?

Other literals are allowed (see the typespec docs) but you cannot specify string literals as keys.

@spec foo(%{required(:email) => String.t()}) :: nil # => ok
@spec foo(%{required("email") => String.t()}) :: nil # => NOT ok

Rather than specifying the key name, you can only specify its type:

@spec foo(%{required(String.t()) => String.t()}) :: nil

All keys and values must be strings. Will accept an empty map.

@spec foo(%{required(atom()) => String.t()}) :: nil

All keys must be atoms and all values must be strings. Will accept an empty map.

@spec foo(%{required(atom()) => String.t(), required(String.t()) => String.t()}) :: nil

Keys must be either atoms or strings. Although it's marked "required", you can pass in a map that does not have any string keys. I don't know why this is so.

If you want key name checking and your keys are strings, coerce them to atoms or cast the map to a struct. DO NOT unconditionally coerce user-supplied params to atom keys. Atoms aren't garbage collected and a malicious user can crash your system by hammering your endpoint with rotating param garbage.

Hanging plants in Rose Café
Hanging plants in Rose Café

Debugging Tips

The output from Dialyzer can be a little confusing at first. Here's a quick reference in case you see these errors:

"the success typing is ..."

What the function actually is doing. You have probably specified the wrong type for your function, or you are returning something from your function that does not match up to the spec.

"the call to <function> breaks the contract ..."

You are calling the function with arguments that do not match the typespec. This could mean:

  • the function is called with the wrong key, the wrong key type, or the wrong value type
  • you are calling the function with unspecified keys. Adding optional(any()) => any() to your spec may resolve this.
  • the typespec (contract) is incorrect

Me on a cookie
A picture of me on a cookie

The Bottom Line

As pleasant as a dynamic language is, typespecs are super helpful for documentation and correctness guarantees, even in conjunction with robust tests. Although they may seem like overkill on small or personal projects, in production code - with its vagaries, loose ends, and operational complexity - they can be extremely helpful, especially when onboarding new engineers.

I coach teams coming to Elixir on best practices. Want to avoid common mistakes? Hire me!

— Desmond Bowe Crevalle founder
Let's work together.

Project? Question? Reach out and say hello.

Stay in touch.

Sign up to our infrequent newsletter to hear what we are thinking about.