Ok_then - The Swiss Army Knife for tagged tuple pipelines

Cool; seems like a reasonable workflow. I figured people must be handling these situations somehow with existing tools. But for my taste, I think the ok_then flow is a little cleaner:

user_id
|> User.get_user()   # Result.maybe(User.t(), any()) = {:ok, User.t()} | :none | {:error, any()}
|> Result.map(&User.modify_user(&1, user_anything))  # Result.maybe(User.t(), any())
|> Result.error_consume(&Logger.error/1)  # If :error, log and replace with :none
|> Result.unwrap_or(&User.default/1)   # Can provide value or function for lazy evaluation
1 Like

Well, for example: I built this library largely out of necessity in refactoring an existing codebase. It’s definitely easier to retrofit ok_then into existing code that returns :ok/:error, because for the happy codepath the data format doesn’t actually change. If I introduce an additional wrapper for :some/:none, then all the code that touches the refactored functions would need to be adjusted to handle the new data format (unwrapping {:some, value}), which makes it tougher to make incremental changes.

The good news, though, is that it’s really easy to get ok_then to work with :some/:none, because all the generic tools are readily available (and documented):

@spec some_unwrap!(Result.result_input()) :: any()
def some_unwrap!(result) do
  Result.tagged_unwrap!(result, :some)
end

{:some, "hello"} |> some_unwrap!()  # "hello"
:none |> some_unwrap!()             # **(ArgumentError) Value is not tagged some: :none.

@spec default_some(input, (() -> out) | out) :: input | {:some, out}
      when input: Result.result_input(), out: any()
def default_some(result, func_or_value) do
  Result.default_as(result, :some, func_or_value)
end

{:some, "hello"} |> Result.default_some("default") # {:some, "hello"}
:none |> Result.default_some("default")            # {:some, "default"}

There’s a Result.ok_or(t, e) type defined that excludes :none, if you want to use that in typespecs, and you can build your own functions pretty easily to simplify the double wrapping and unwrapping, if you like:

"hello"
|> Result.from_as(:some)        # {:some, "hello"}
|> Result.from()                # {:ok, {:some, "hello"}}
|> Result.unwrap!()             # {:some, "hello"}
|> Result.tagged_unwrap!(:some) # "hello"

nil
|> Result.from_as(:some)        # :none
|> Result.from()                # {:ok, :none}
|> Result.unwrap!()             # :none
|> Result.tagged_unwrap!(:some) # **(ArgumentError) Value is not tagged some: :none.

I’d actually be open to adding a module in ok_then to support this paradigm further.

I think that conflict highlights the utility of other languages having split apart Option/Maybe from Result. The former is for avoiding nil problems the latter for consistent error handling. So maybe there needs to be a separate module for the Option/Maybe portion with some conversion functions to allow turning them into Results and vice versa.

1 Like

That could be interesting, yes. There certainly would need to be more buy-in for a pattern like this, but I think we could provide support with an additional Option module. The significant advantage I wanted to introduce with ok_then was making it hard to produce {:ok, nil}, and a solid mechanism for falling back to default values. For this purpose, I do think {:ok, value} | :none works just as well as {:some, value} | :none.

The existing Result module also has the benefit that if code later begins returning {:error, reason} values too, the downstream code won’t break, since Result.unwrap_or_else/2 will return the “else” for any tag that isn’t :ok - you might just want to add Result.error_consume/2 somewhere to log the error. In Rust, the type would change from Option<T> to Result<Option<T>>, and the compiler would provide help in adjusting downstream code. We don’t get that in Elixir.

But yeah, for people who want to reserve Result types for :ok/:error, and want a separate type for :some/:none, it should be quite easy to add additional support. The Result module itself probably could be used as-is, since in theory it’ll only be used to wrap Options, which should never be nil. I’m imagining something like this?

"hello"
|> Option.from()                    # {:some, "hello"}
|> Result.from()                    # {:ok, {:some, "hello"}}
|> Result.unwrap!()                 # {:some, "hello"}
|> Option.unwrap_or_else("default") # "hello"

And then I guess we’d want methods such as Option.default, Option.or_else etc…, pretty much identical to existing Result functions, but with :some as the happy tag instead of :ok.

2 Likes

I just came across a library function that returns a very non-standard list() | {:error, integer(), any()}, but was pleasantly surprised to realise that ok_then can already handle this situation quite nicely:

[]
|> Result.tagged_retag(:untagged, :ok)                # {:ok, []}
|> Result.error_map(fn {_code, reason} -> reason end) # {:ok, []}

{:error, 404, "not found"}
|> Result.tagged_retag(:untagged, :ok)                # {:error, 404, "not found"}
|> Result.error_map(fn {_code, reason} -> reason end) # {:error, "not_found"}
2 Likes

Version 1.0.0 has been released, with the following notable additions:

Result.filter(result, check_function)

This function converts an :ok tuple to :none if a condition is not satisfied:

iex> Result.from("")                           # {:ok, ""}
...> |> Result.filter(&String.length(&1) > 0)  # :none
...> |> Result.default("Sensible Value")
{:ok, "Sensible Value"}

iex> Result.from(nil)
...> |> ...
{:ok, "Sensible Value"}

iex> Result.from("Chosen")
...> |> ...
{:ok, "Chosen"}

Result.Enum.map_grouped_by_tag(results, map_function)

This function allows you to map or filter all results in an enumerable that match a certain tag:

iex> [{:ok, 1}, {:error, 1}, {:ok, 2}, {:error, 2}, :none]
...> |> Result.Enum.map_grouped_by_tag(fn
...>   :ok, values -> Enum.map(values, &(&1 + 1))
...>   :error, values -> Enum.take(values, 1)
...>   :none, _values -> []
...> end)
[{:error, 1}, {:ok, 2}, {:ok, 3}]
1 Like

Version 1.1.0 has been released, with the following notable additions:

Result.tap(result, func_or_value) and related functions
This function behaves like Kernel.tap/2, but calls the function only if the result was tagged as expected:

iex> {:ok, "hello"} |> Result.tap(&IO.puts/1)
hello
{:ok, "hello"}

A 0-arity function is accepted, if you only really care about :ok/:error, rather than the value:

iex> {:ok, "hello"} |> Result.tap(fn -> do_something() end)
{:ok, "hello"}

The function is not called if the tag doesn’t match.
Naturally, error_tap/2 and tagged_tap/3 are available too.

Result.default!(result, value) and family
If you know a default value should never be nil, a bang-variant of the “default” functions is now available that will raise if the value is nil, instead of returning :none:

iex> :none |> Result.default!("hello")
{:ok, "hello"}

iex> :none |> Result.default!(nil)
** (ArgumentError) Value is nil.

Result.filter/2 now accepts 0-arity functions and truthy values
Previously this function accepted only a 1-arity function, but there are situations where the value is irrelevant, or relies on an external value, and this is now possible:

iex> {:ok, "hello"} |> Result.filter(fn -> true end)
{:ok, "hello"}

iex> {:ok, "hello"} |> Result.filter(false)
:none
1 Like