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.
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.
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
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!
Project? Question? Reach out and say hello.
Sign up to our infrequent newsletter to hear what we are thinking about.