Keyword list reduction
Keyword lists are often used to provide optional values, which are then processed (for example to initialize the state). One really nice way to do so, is with a reduce pattern.
I’ve never seen anyone bringing attention to it in training materials I’ve seen on Elixir (although I did see it mentioned in this thread). Given how prevalent keyword list processing is for option handling, and how readable the resulting code is, I thought I’d mention it here.
Here’s, the code:
defmodule ReducerDemo do
@options [:name, :timeout]
def init([type, count | opts])
when is_atom(type) and is_integer(count) and count > 0 do
init(opts, %{type: type, count: count})
end
defp init([], state), do: {:ok, state}
defp init([{:timeout, _} | _rest], %{type: :immediate}) do
{:stop, {:conflicting_option,
"cannot use `timeout` option with type :immediate"}}
end
defp init([{:timeout, t} | rest], state)
when is_integer(t) and t > 0 do
init(rest, Map.put(state, :timeout, t))
end
defp init([{:name, n} | rest], state) when is_atom(n) do
init(rest, Map.put(state, :name, n))
end
defp init([{name, value} | _], _state) when name in @options do
{:stop, {:invalid_option,
"invalid value `#{value}` given to option `#{name}`"}}
end
defp init([{name, _value} | _], _state) do
{:stop, {:invalid_option, "`#{name}` is not a valid option"}}
end
end
Let’s say init/1
is a callback for a GenServer: it should return {:ok, state}
or
{:stop, reason}
(see docs). Within the list, it expects 2 mandatory arguments, along with an optional list of options. (Note that all of these are provided within a list due to the callback’s arity.)
In the public init/1
function head, we typecheck the mandatory arguments. Then, within its body, we call a private init/2
function with the list of options and the initial state containing the mandatory values.
On line 9, we have the actual return taking place: if no options are left to process, just return the current state we’ve built up.
Let’s ignore lines 11-14 for now, and swing back to them later.
Lines 16-23 represent the happy path: match one a variable (with guards ensuring it’s the expected type), add it to the state, and recursively call init/2
. Recall that a keyword list such as [a: 1, b:2]
is just nicer syntax for the real representation, which is a list of tuples: [{:a, 1}, {:b, 2}]
(read more about that here). In order to pattern match, we need to rely on the tuple representation.
Finally, we get to error handling. The first type of errors we handle are conflicting options on lines 11-14: if we discover an option that is incompatible with one already provided/processed, we return an error. Error functions return directly: there is no further processing of remaining options since we don’t recursively call init/2
. The {:stop, reason}
tuple is returned only because this example dealt with initializing state for a GenServer, and that is the expected error return value (see docs). Of course, your own code should return whatever makes sense in the current context.
We’ve got 2 more cases to handle: we were provided a valid option with an invalid value, or we were given an invalid option.
The first case is handled with the function on line 25: if the option name is in the whitelisted options (defined as a module attribute on line 2), then its value is incorrect.
If one the other hand an unrecognized option was provided, the function head on line 30 will be matched and return the appropriate message.
Let’s give it a spin:
iex(1)> c "reducer_demo.ex"
iex(2)> ReducerDemo.init([:delayed, 3, name: :foobar, timeout: 500])
{:ok, %{count: 3, name: :foobar, timeout: 500, type: :delayed}}
iex(3)> ReducerDemo.init([:delayed, 3, timeout: 500])
{:ok, %{count: 3, timeout: 500, type: :delayed}}
iex(4)> ReducerDemo.init([:delayed, 3])
{:ok, %{count: 3, type: :delayed}}
iex(5)> ReducerDemo.init([:immediate, 3, timeout: 500])
{:stop, {:conflicting_option, "cannot use `timeout` option with type :immediate"}}
iex(6)> ReducerDemo.init([:delayed, 3, nane: :foobar])
{:stop, {:invalid_option, "`nane` is not a valid option"}}
iex(7)> ReducerDemo.init([:delayed, 3, name: "foobar"])
{:stop, {:invalid_option, "invalid value `foobar` given to option `name`"}}
Finally, astute readers will have noticed that the “reason” tagged tuples on lines 26 and 31 have the same tag and just differ in their message. This was an arbitrary API choice: in this case, it would be expected that clients only match on the tag (therefore being unable to determine whether the error was caused by an invalid option name or value). In other words, the string message isn’t intended for flow control, it’s there for the developer: if an invalid option pops up, the message will tell you if you’ve mistyped an option name or if the value was invalid.
Would you like to see more Elixir content like this? Sign up to my mailing list so I can gauge how much interest there is in this type of content.