The Swiss Army Knife for tagged tuple pipelines
Looking for a better way to handle errors, optional results, and default values? Wish there were a really consistent and full-featured API to handle tagged tuples in pipelines?
Wrap values:
Result.from("hello") # {:ok, "hello"}
Result.from(1) # {:ok, 1}
Result.from(nil) # :none
Result.from_error(1) # {:error, 1}
Result.from_as(1, :some) # {:some, 1}
Map values selectively by tag:
def pipeline(value_r) do
value_r
|> Result.map(& &1 * 2)
|> Result.error_map(& {:bad_input_value, &1})
|> Result.tagged_map(:add_2, & &1 + 2)
end
Result.from(1) |> pipeline() # {:ok, 2}
Result.from(nil) |> pipeline() # :none
Result.from_error(1) |> pipeline() # {:error, {:bad_input_value, 1}}
Result.from_as(1, :add_2) |> pipeline() # {:add_2, 3}
Or apply functions selectively by tag:
def double(value), do: {:ok, value * 2}
def error(value), do: {:error, value}
{:ok, 1} |> Result.then(&double/1) |> Result.then(&double/1) # {:ok, 4}
{:ok, 1} |> Result.then(&error/1) |> Result.then(&double/1) # {:error, 1}
{:ok, 1} |> Result.then(&double/1) |> Result.then(&error/1) # {:error, 2}
{:ok, 1} |> Result.then(&error/1) |> Result.error_then(&double/1) # {:ok, 2}
And handle unexpected values safely:
def to_nil(_value), do: nil
def error(value), do: {:error, value}
def unwrap_result(result) do
result
|> Result.default("default")
|> Result.unwrap_or_else("failsafe")
end
Result.from(1) # {:ok, 1}
|> unwrap_result() # 1
Result.from(1) # {:ok, 1}
|> Result.map(&to_nil/1) # :none
|> unwrap_result() # "default"
Result.from(1) # {:ok, 1}
|> Result.then(&error/1) # {:error, 1}
|> unwrap_result() # "failsafe"
Typespecs:
@spec a() :: Result.ok_or(any())
@spec a() :: :ok | {:error, any()}
@spec b() :: Result.ok_or(integer(), any())
@spec b() :: {:ok, integer()} | {:error, any()}
@spec c() :: Result.maybe(integer())
@spec c() :: {:ok, integer()} | :none
@spec d() :: Result.maybe(integer(), any())
@spec d() :: {:ok, integer()} | :none | {:error, any()}
You can even handle tagged tuples inside Enums:
# Tags other than :ok and :error are supported too :)
[{:ok, 1}, {:ok, 2}, {:ok, 3}, {:error, 4}, {:error, 5}]
|> Result.Enum.group_by_tag()
%{
error: [4, 5],
ok: [1, 2, 3]
}
[{:ok, 1}, {:ok, 2}, {:ok, 3}]
|> Result.Enum.collect()
{:ok, [1, 2, 3]}
[{:ok, 1}, {:ok, 2}, {:error, 3}, {:ok, 4}]
|> Result.Enum.collect()
{:error, 3}
Check out the API documentation for a full list of supported functions, guards, and types.
Because:
- Remembering to check for
nil
is the bane of any programmer's life. It pops up everywhere. - Tagged tuples, the idiomatic solution to this problem, can become verbose, especially when they need to be passed to several functions, or through a pipeline.
- Although
{:ok, value} | {:error, reason}
is ubiquitous, there is no standardised pattern to represent optional values, other thanvalue | nil
.
Failing to address point 3 leads to code that either:
-
Returns
{:ok, nil}
, which brings us right back to unexpectednils
popping up in the most obscure ways:** (UndefinedFunctionError) function nil.my_map_key/0 is undefined.
-
Returns
{:error, :not_found}
or similar, which is often semantically questionable: missing values are often not actually errors. This can lead to confusion in determining how best to handle fallback values and error logging.
One solution (adopted by languages such as Rust), is to provide a return type that is explicitly optional. In Elixir we could represent this with an orthogonal type of tagged tuple:
{:some, value} | :none
The main drawback of this approach is how verbose the tuples can become in log output, especially when nested.
{:ok, {:some, %MyStruct{key: "value"}}}
{:ok, :none}
{:error, {:some, "Example Error"}}
{:error, :none} # Is this an error, or a lack of error?
To mitigate this issue, the approach taken by this package is to combine the "ok/error" and "some/none" types into a single type of tagged tuple called a "maybe":
{:ok, value} | :none | {:error, reason}
- Specific functions are provided to handle tagged tuples with these tags (
:ok, :none, :error
). - Generic functions also exist to handle any tag, but are slightly less convenient.
- Functions that create or map tagged tuples will catch
nil
values and transform the returned result into:none
.
This time, we can handle an unexpected nil
far more elegantly. Here is a
slightly contrived example:
get_my_map() # nil
|> Result.from() # :none
|> Result.default(%{}) # {:ok, %{}}
|> Result.map(&Map.get(&1, :my_map_key)) # :none
|> Result.unwrap_or_else("default") # "default"
And now imagine we could introduce an unexpected error. This is not altered by
default
, which only affects results tagged :none
. However, unwrap_or_else
will catch any result that is not :ok
:
{:error, "Unexpected Error"}
|> Result.default(%{}) # {:error, "Unexpected Error"}
|> Result.map(&Map.get(&1, :my_map_key)) # {:error, "Unexpected Error"}
|> Result.unwrap_or_else("default") # "default"
Even better, we could "consume" the error by logging it, then handle the missing value just like before:
{:error, "Unexpected Error"}
|> Result.error_consume(&Logger.error/1) # :none ("Unexpected Error" is logged)
|> Result.default(%{}) # {:ok, %{}}
|> Result.map(&Map.get(&1, :my_map_key)) # :none
|> Result.unwrap!() # "default"
Take a look at some more examples.
Simply add the package to your deps in mix.exs
:
def deps do
[
{:ok_then, "~> x.x.x"} # Check "hex" badge at the top for current version
]
end