Packages

Module with helper functions for handling {:ok, value} or {:error, reason} return values

Current section

Files

Jump to
result_ex lib result_ex.ex
Raw

lib/result_ex.ex

defmodule ResultEx do
@moduledoc """
ResultEx is a module for handling functions returning a `t:ResultEx.t/0`.
This module is inspired by the f# Result module, and [Railway Oriented Programming](https://fsharpforfunandprofit.com/rop/) as explained by Scott Wlaschin.
A result can be either the tuple {:ok, term} where term will be the expected return value of a function,
or the tuple {:error, term} where term will be an explanation of what went wrong while executing a function.
Using this module, it will be possible to combine functions that return a `t:ResultEx.t/0`, and functions that take the value contained by the ok variant.
In the case one of the functions returns an error variant, subsequent functions expecting an ok result can be prevented from being executed.
Also, functions can be connected that will only execute in the case of an error.
## Examples
iex> defmodule ResultExExample do
...>
...> def divide(0, _), do: {:error, :zero_division_exception}
...> def divide(0.0, _), do: {:error, :zero_division_exception}
...> def divide(x, y), do: ResultEx.return(x / y)
...> def subtract(x, y), do: ResultEx.return(x - y)
...>
...> end
...>
...> ResultExExample.divide(4, 2)
...> |> ResultEx.bind(fn x -> ResultExExample.subtract(x, 2) end)
{:ok, 0.0}
iex> ResultExExample.divide(4, 2)
...> |> ResultEx.bind(fn x -> ResultExExample.subtract(x, 2) end)
...> |> ResultEx.bind(fn x -> ResultExExample.divide(x, 2) end)
...> |> ResultEx.bind(fn x -> ResultExExample.subtract(x, 2) end)
{:error, :zero_division_exception}
iex> ResultExExample.divide(0, 2)
...> |> ResultEx.or_else(2)
2
iex> ResultExExample.divide(0, 2)
...> |> ResultEx.or_else_with(fn _err -> {:ok, 0} end)
{:ok, 0}
"""
@type t ::
{:ok, term}
| {:error, term}
@doc """
Elevates a value to a `t:ResultEx.t/0` type.
## Examples
iex> ResultEx.return(1)
{:ok, 1}
"""
@spec return(term) :: t
def return(value), do: {:ok, value}
@doc """
Runs a function against the `t:ResultEx.t/0`s value.
If the `t:ResultEx.t/0` is an error, the function will not be executed.
## Examples
iex> result = {:ok, 1}
...> ResultEx.map(result, &(&1 + 1))
{:ok, 2}
iex> result = {:error, "Oops"}
...> ResultEx.map(result, &(&1 + 1))
{:error, "Oops"}
"""
@spec map(t, (term -> term)) :: t
def map({:ok, value}, fun) do
{:ok, fun.(value)}
end
def map(result, _), do: result
@doc """
Partially applies `ResultEx.map/2` with the passed function.
"""
@spec map((term -> term)) :: (t -> t)
def map(fun) do
fn result -> map(result, fun) end
end
@doc """
Executes or partially executes the function given as value of the first `t:ResultEx.t/0`,
and applies it with the value of the second `t:ResultEx.t/0`.
If the function has an arity greater than 1, the returned `t:ResultEx.t/0` value will be the function partially applied.
(The function name is 'appl' rather than 'apply' to prevent import conflicts with 'Kernel.apply')
## Examples
iex> value_result = {:ok, 1}
...> function_result = {:ok, fn value -> value + 1 end}
...> ResultEx.appl(function_result, value_result)
{:ok, 2}
iex> {:ok, fn value1, value2, value3 -> value1 + value2 + value3 end}
...> |> ResultEx.appl({:ok, 1})
...> |> ResultEx.appl({:ok, 2})
...> |> ResultEx.appl({:ok, 3})
{:ok, 6}
iex> {:error, "no such function"}
...> |> ResultEx.appl({:ok, 1})
...> |> ResultEx.appl({:ok, 1})
...> |> ResultEx.appl({:ok, 1})
{:error, "no such function"}
iex> {:ok, fn value1, value2, value3 -> value1 + value2 + value3 end}
...> |> ResultEx.appl({:ok, 1})
...> |> ResultEx.appl({:ok, 1})
...> |> ResultEx.appl({:error, "no such value"})
{:error, "no such value"}
"""
@spec appl(t, t) :: t
def appl({:ok, fun}, {:ok, value}) do
case :erlang.fun_info(fun, :arity) do
{_, 0} ->
{:error, "ResultEx.appl: arity error"}
_ ->
{:ok, curry(fun, value)}
end
end
def appl({:error, _} = error, _), do: error
def appl(_, {:error, _} = error), do: error
@doc """
Applies a function with the value of the `t:ResultEx.t/0`.
The passed function is expected to return a `t:ResultEx.t/0`.
This can be useful for chaining functions together that elevate values into `t:ResultEx.t/0`s.
## Examples
iex> divide = fn
...> 0 -> {:error, "Zero division"}
...> n -> {:ok, n / 2}
...> end
...> divide.(4)
...> |> ResultEx.bind(divide)
{:ok, 1.0}
iex> divide = fn
...> 0 -> {:error, "Zero division"}
...> n -> {:ok, n / 2}
...> end
...> divide.(0)
...> |> ResultEx.bind(divide)
{:error, "Zero division"}
"""
@spec bind(t, (term -> t)) :: t
def bind({:ok, value}, fun) do
fun.(value)
end
def bind(result, _), do: result
@doc """
Partially applies `ResultEx.bind/2` with the passed function.
"""
@spec bind((term -> t)) :: (t -> t)
def bind(fun) do
fn result -> bind(result, fun) end
end
@doc """
Unwraps the `t:ResultEx.t/0` to return its value.
Throws an error if the `t:ResultEx.t/0` is an error.
## Examples
iex> ResultEx.return(5)
...> |> ResultEx.unwrap!()
5
"""
@spec unwrap!(t) :: term
def unwrap!({:ok, value}), do: value
def unwrap!({:error, error}), do: throw(error)
@doc """
Unwraps the `t:ResultEx.t/0` to return its value.
The second argument will be a specific error message to throw when the `t:ResultEx.t/0` is an Error.
## Examples
iex> ResultEx.return(5)
...> |> ResultEx.expect!("The value was not what was expected")
5
"""
@spec expect!(t, String.t()) :: term
def expect!({:ok, value}, _), do: value
def expect!(_, message), do: throw(message)
@doc """
Unwraps the `t:ResultEx.t/0` to return its value.
If the `t:ResultEx.t/0` is an error, it will return the default value passed as second argument instead.
## Examples
iex> ResultEx.return(5)
...> |> ResultEx.or_else(4)
5
iex> {:error, "Oops"}
...> |> ResultEx.or_else(4)
4
"""
@spec or_else(t, term) :: term
def or_else({:ok, value}, _), do: value
def or_else(_, default), do: default
@doc """
Unwraps the `t:ResultEx.t/0` to return its value.
If the `t:ResultEx.t/0` is an error, the given function will be applied with the unwrapped error instead.
## Examples
iex> ResultEx.return(5)
...> |> ResultEx.or_else_with(fn err -> IO.inspect(err) end)
5
iex> {:error, "Oops"}
...> |> ResultEx.or_else_with(fn err -> err <> "!" end)
"Oops!"
"""
@spec or_else_with(t, (term -> term)) :: term
def or_else_with({:ok, value}, _), do: value
def or_else_with({:error, error}, fun), do: fun.(error)
@doc """
Flatten nested `t:ResultEx.t/0`s into a single `t:ResultEx.t/0`.
## Examples
iex> ResultEx.return(5)
...> |> ResultEx.return()
...> |> ResultEx.return()
...> |> ResultEx.flatten()
{:ok, 5}
iex> {:ok, {:ok, {:error, "Oops"}}}
...> |> ResultEx.flatten()
{:error, "Oops"}
"""
@spec flatten(t) :: t
def flatten({:ok, {:ok, _} = inner_result}) do
flatten(inner_result)
end
def flatten({:ok, {:error, _} = error}), do: error
def flatten({:ok, _} = result), do: result
def flatten({:error, _} = error), do: error
@doc """
Flattens an `t:Enum.t/0` of `t:ResultEx.t/0`s into a `t:ResultEx.t/0` of enumerables.
## Examples
iex> [{:ok, 1}, {:ok, 2}, {:ok, 3}]
...> |> ResultEx.flatten_enum()
{:ok, [1, 2, 3]}
iex> [{:ok, 1}, {:error, "Oops"}, {:ok, 3}]
...> |> ResultEx.flatten_enum()
{:error, "Oops"}
iex> %{a: {:ok, 1}, b: {:ok, 2}, c: {:ok, 3}}
...> |> ResultEx.flatten_enum()
{:ok, %{a: 1, b: 2, c: 3}}
iex> %{a: {:ok, 1}, b: {:error, "Oops"}, c: {:ok, 3}}
...> |> ResultEx.flatten_enum()
{:error, "Oops"}
"""
@spec flatten_enum(Enum.t()) :: t
def flatten_enum(%{} = enum) do
Enum.reduce(enum, {:ok, %{}}, fn
{key, {:ok, value}}, {:ok, result} ->
Map.put(result, key, value)
|> return
_, {:error, _} = error ->
error
{_, {:error, _} = error}, _ ->
error
end)
end
def flatten_enum(enum) when is_list(enum) do
Enum.reduce(enum, {:ok, []}, fn
{:ok, value}, {:ok, result} ->
{:ok, [value | result]}
_, {:error, _} = error ->
error
{:error, _} = error, _ ->
error
end)
|> map(&Enum.reverse/1)
end
def flatten_enum(_), do: {:error, "ResultEx.flatten_enum Unknown Type"}
@doc """
Converts the `t:ResultEx.t/0` to an Option.
An Option is a {:some, term} tuple pair, or the :none atom.
## Examples
iex> ResultEx.return(5)
...> |> ResultEx.to_option()
{:some, 5}
iex> {:error, "Oops"}
...> |> ResultEx.to_option()
:none
"""
@spec to_option(t) :: {:some, term} | :none
def to_option({:ok, value}) do
{:some, value}
end
def to_option({:error, _}), do: :none
@spec curry(fun, term) :: term
defp curry(fun, arg1), do: apply_curry(fun, [arg1])
@spec apply_curry(fun, [term]) :: term
defp apply_curry(fun, args) do
{_, arity} = :erlang.fun_info(fun, :arity)
if arity == length(args) do
apply(fun, Enum.reverse(args))
else
fn arg -> apply_curry(fun, [arg | args]) end
end
end
end