Typespec for map w/both required and optional keys

Expanding on this topic: Map typespec question

Let’s say I have a map with required and optional keys. I’d like to document both, but based on the docs it seems like you can’t denote a specific key is optional. Where else do people document these optional keys?

@type params :: %{foo: String.t, optional(:atom) => integer()}
@doc """
Does a thing

## Examples

    iex> do_thing(params)
    {:ok, "thing_done"}

## Parameters

    %{
        foo: required(string),
        bar: optional(integer)
      }
"""
@spec do_thing(params) :: {:ok, String.t}
def do_thing(...), do: {:ok, "thing_done"}

With that example in mind, how do I specify in the @type params declaration that :bar is optional? Just leave it out and document its optionality in the @doc?

Thanks for the help!

1 Like

Perhaps do it with the value.

@type parmas :: %{foo: String.t, bar: integer() | nil}

Or maybe:

@type optional_integer :: integer() | nil
@type parmas :: %{foo: String.t, bar: optional_integer()}
1 Like

You already have the key :atom marked as optional. So what do you want more?

I actually want optional(:bar) => integer() but AFAICT from the docs I have to specify the key_type not the key.

@type parmas :: %{foo: String.t, bar: integer() | nil}

Is close, but that still looks like :bar is a required key even if the value of :bar is nil. Those seem like different things.

If I pattern matched the function args, :bar would not be present:

def do_thing(%{foo: foo)), do...

Literals are their own type. So optional(:bar) is exactly that.

10 Likes

Ah, yes! I see that now. Makes sense. Thanks!

%{
  account_number: String.t(),
  optional(:counterparty_id) => integer(),
  receiver_account_number: String.t() unless :counterparty_id set
}

Since I have your attention (:wink:) is there a way to specify something is required if another key is not present? In this case, you can either have a :counterparty_id or several :receiver_* pairs.

1 Like

No, neither the typespec syntax nor dialyzers checks can deal with mutual exclusion of keys.

1 Like

Yep, use the union operator |. :slight_smile:

I.E. you specify the whole structure twice with each variation, like:

%{
  account_number: String.t(),
  :counterparty_id => integer(),
} | %{
  account_number: String.t(),
  receiver_account_number: String.t()
}

It’s a bit of a pain and combinatorially explosive, but it works. :slight_smile:

4 Likes

A potential stumbling block: even though the keys look like atoms, you can’t mix the x: and => when using optional. I assume this is because optional(:bar) resolves to a non-atom type.

You might get the somewhat cryptic error:

@type params :: %{
  foo: String.t(),
  optional(:bar) => integer() # "syntax error before: optional"
}

You must convert to:

@type params :: %{
  foo => String.t(),
  optional(:bar) => integer()
}

E: see below, or

@type params :: %{
  optional(:bar) => integer(),
  foo: String.t()
}
3 Likes

The syntax sugar for keyword lists as well as for atom keys in maps is only allowed trailing to elements not using the syntax sugar within the same map/list. Though I‘m not sure if the same does work in typespecs.

3 Likes

It does.

Thank you for posting this - I was confused about exactly this error message and found your post.

I’ve opened an issue: Unhelpful error for typespecs with inconsistent key syntax · Issue #10973 · elixir-lang/elixir · GitHub

1 Like