Ok_then - The Swiss Army Knife for tagged tuple pipelines

GitHub: GitHub - flexibility-org/ok_then: The Swiss Army Knife for tagged tuple pipelines
HexDocs: OK then... — ok_then v0.1.0

I just couldn’t quite find what I was looking for when it came to handling tagged tuples. In particular, I really wanted to find an elegant solution to the problem of missing return values that are not errors. Because sometimes, a missing return value is an error, and other times it’s not. Rust handles this very elegantly, using separate Result (Ok / Error) and Option (Some / None) types, often in tandem.

So I decided I had to write my own solution for handling tagged tuples. And I’m quite pleased with the result. Of course I have to give credit to those who have gone before, such as the authors of the “OK” and “Croma” packages.

The most controversial feature will be the addition of :none alongside :ok and :error, though this becomes mostly transparent in pipelines.

There are a few more functions I’d like to add, in particular to the Result.Enum module. And I’d also like a monadic “with”-like macro, similar to that offered by the “OK” package.

3 Likes

Seems like a neat idea! You’ve already mentioned that you think adding :none might be controversial - why do you think it’s still useful?
After all, nil is also simply an atom (is_atom(nil) == true and nil == :nil) :slight_smile:

Erlang uses :none in it’s standard lib, so not it’s not unheard of, but since nil is already the convention in elixir, I have the same question.

The exceptional library does that, a comparison between your solution and that one would be nice :slight_smile:

You haven’t found Result library? And course for

there is no standardised pattern to represent optional values , other than value | nil

there is ExMaybe

I always have found undefined as a default value meaning “not found”.

There are some functions that return none, like erlang:dist_ctrl_get_data, erlang:system_info, but it seems in those cases it means “set to none” or “not available” rather than “not found”

2 Likes

There are two main reasons:

Semantics: There is no particular convention to determine the meaning of nil - when I see nil in an error message, the cause (generally a missing nil-check) could be anywhere in that stack trace. However, by wrapping nil into :none, I know that when I see :none in a stack trace, I’m forgetting to pattern-match a tagged tuple, usually right at the top of the stack trace. Technically, the difference is very small - as you say, both nil and :none are atoms - but in practice the name difference is helpful.

Another way to look at this is by typing: :none is a valid value for the type Result.maybe(any()), whereas nil is the absence of any value at all. It could have been :no_result or something similarly more explicit to mark the “type” of the tuple, but I decided it was more pragmatic to keep the tag name short, and “none” is also easier to mirror in function names than the latter.

Consistency: To make functions more generic, results can be normalized into two-element tuples: {atom(), any()}. This happens internally, but you can also see this in functions such as or_else. For atom results such as :ok, this becomes {:ok, {}}. For :none this becomes {:none, {}}. Although nil is an atom, I think it’s a little less clear: {nil, {}}. It just looks a bit too much like a mistake or oversight to me - as if the tag itself is missing or undefined, which is not the intended meaning.

I don’t think it addresses optional values (e.g. accidentally mapping a wrapped value to nil)? I may have missed it. I did examine that package briefly, but the functionality seems to be aimed mainly at handling exceptions in pipelines, which is also a really nice goal, but different. I felt I first wanted something focused on a really clean and consistent API for regular tagged tuples. So far I’ve managed to mostly avoid exceptions in pipelines by catching them early, near the cause, but I’d definitely try exceptional if that became impractical.

What does a new convention of :ok / :none achieve that hasn’t been achieved by the :ok / :error convention?

If a function does not return data then it can just return :ok (not a tuple).

When you say semantics and consistency, I am not seeing it. It’s OK to have a personal preference but I don’t see the value.

As for nil, well, I think we all know that’s a huge and very expensive mistake in computer science in general. But as others pointed out, nil is also an atom so replacing it with another one doesn’t help much. Not sure I am seeing the argument for the stack traces as well.

Maybe I just don’t get it though.

2 Likes

Yes; I did check out that library. Again, it doesn’t really handle optional values, and results are expected to only be tagged :ok or :error, whereas ok_then accepts any tagged tuple (though :ok, :error, and :none have more convenient functions). I also felt like the API wasn’t quite as full or consistent as I’d have liked.

As far as I can tell, this package adopts the value | nil paradigm, though it does offer some semantics that make it easier to chain multiple nil-checks together in a pipeline. It doesn’t scratch my itch, because I feel that tagged tuples are semantically clearer and also safer.

You mean it could return :ok | {:ok, value}? Yes, you could certainly do that. Personally I find it less clear, because it’s not immediately clear that :ok is a result that could have held a value. It could be made explicit with {:ok, {}}, for instance, and in fact ok_then interprets :ok in that way:

def example(value_r) do
  value_r
  |> Result.map(fn
    {} -> "default"
    value -> value
  end)
  |> Result.unwrap!()
end

{:ok, "hello"} |> example()   # "hello"
:ok |> example()              # "default"
:error |> example()           # **(ArgumentError)

So imagine that instead of tagged tuples, we use structs:

%Result{is_empty: false, is_ok: true, value: "hello"}
%Result{is_empty: true, is_ok: true, value: nil}
%Result{is_empty: false, is_ok: false, value: "reason"}

In every case, what is returned is a Result, which should be unwrapped before it’s used directly. The spec for a function returning this would use Result.t() (or similar). In every case, it’s a Result that is returned. Returning nil would clearly mean that you’re not returning a Result.

What I’m suggesting is that semantically, :ok | :error | :none is exactly the same. The type is Result.maybe(). Returning nil would mean that we’re not returning a Result.maybe(). This helps because if I see an error such as :none.hello/0 is undefined, then I know what I’m dealing with is a Result.maybe() pretty much exactly where the exception was raised that has not been unwrapped - it’s being treated as an unwrapped value. Unwrapping the result with unwrap_or/2 will force you to consider a default value. If I see an error such as nil.hello/0 is undefined, or - more likely - an obscure Ecto exception caused by a nil leaking into a query - I need to work up the stack to find out where I should have checked for that nil, because there’s nothing to “unwrap” here - it’s simply a value that isn’t there.

Ah, agreed.

Agreed as well, nil should be avoided like the plague.

That works with many other return values though, including with a plain :ok or :error (no tuple return value, just a single atom).


As another Rust guy I can see where you’re coming from. But my view is that beyond avoiding nil at all costs, the rest is kind of achievable with fairly vanilla Elixir. But I get the need: you want chainable / pipeable value, which is fair.

Well, in old Erlang versions it worked with tuples as well (and it still can be enabled via tuple_calls compile option).

1 Like

Yes, absolutely. The ok_then library will also accept any bare atom (:some, :success, :ok, …). It interprets these internally as {:ok, {}}, {:some, {}}, ..., so mapping functions will receive {} as the wrapped value, and that’s what unwrap!() would return too. It’s just that :none is returned by Result.from/1 and Result.map/2 if they receive nil as a value to wrap, and there are shorthand functions to handle :none, such as default/2 and none_then/2.

So if you want, you could fall back to a bare :ok quite easily like this:

Result.from(nil)      # :none
|> Result.default({}) # :ok

# or

Result.from(nil)         # :none
|> Result.none_then(:ok) # :ok

I’d be interested in some testcases, actually. Could I tempt you to come up with some examples of vanilla Elixir that we could compare to an equivalent using the ok_then library?

2 Likes

whereas ok_then accepts any tagged tuple (though :ok, :error, and :none have more convenient functions)

According to my experiences it’s bad practice to return just :ok or :error (nil is questionable) from called functions (where the Result type has a sense). You did ecto query to db but nothing was found? The returned value isn’t an error, just return tuple {:ok, "User not found"}. Did you get an error? Return tuple {:error, message}. That’s Result type (success or fail, whereas maybe is value or not defined). These messages can be then shown on client. What tell you the returned information :ok or :error? So if you follow the KISS rule, just enough to define Result as Result.t() :: {:ok, any()} | {:error, msg}.

I also felt like the API wasn’t quite as full or consistent as I’d have liked.

Okay what prevents to you write {:ok, {:some, ...}}?

This helps because if I see an error such as :none.hello/0 is undefined, then I know what I’m dealing with is a Result.maybe().

I’m not sure if this is helpful. Yes, you’ll only know that you’ll deal with Result.maybe(). But you won’t know why. So you have to go back to your logs/stack trace/… and find what caused the error. Maybe it will be a nil in passing argument of called function and then you are back with dealing nil

1 Like

I tend to agree, especially for errors. (I do think :ok makes sense in side-effect-only functions, e.g. logging, assertions.) My design philosophy for the library was to accept as much as possible as input, though. Functions will not crash even if the input is not a tagged tuple. Untagged input is interpreted internally as {:untagged, value}, though this only becomes visible if the function needs to act on the wrapped value:

"hello"
|> Result.unwrap!()
**(ArgumentError) Value is not tagged ok: {:untagged, "hello"}.

# also:

{:ok, 1, 2, 3}
|> Result.unwrap!()
{1, 2, 3}

Also, I think Result pipelines can make sense within functions too; not just to process function return values. Especially when calling functions with unclear return value semantics:

get_a_map_that_could_be_nil()        # could be nil
|> Result.from()
|> Result.map(&Map.get(&1, :my_key)) # skipped if map was nil; Map.get/2 could return nil
|> Result.unwrap_or("default value") # will handle nil at _any_ point

This saves a couple of more verbose case expressions.

Sure, that could make sense if you’re close to the UI:

do_query()
|> Result.error_then(fn
  :not_found ->  {:ok, "User not found"}
  reason -> {:error, reason}
end)

# or

do_query()
|> Result.or_else(fn
  :error, :not_found -> {:ok, "User not found"}
  :error, reason -> {:error, reason}
end)

# and if you're only interested in logging the error:

do_query()
|> Result.error_consume(&Logger.error/1)  # :none
|> Result.default("User not found")       # {:ok, "User not found"}

I certainly considered this - it’s documented in the README. But ultimately I think the issue is that it can become quite difficult to read in log and console output, and the additional layer of unwrapping could break existing functions that expect {:ok, value}. So I combined the two types {:ok, value} | {:error, reason} and {:some, value} | :none into a single type: {:ok, value} | :none | {:error, reason}.

It’s possible, but I think it’s less likely: I’ve found that in practice, having a Result pipeline makes it more likely that I’ll consider default/fallback values at each point. When I call Result.from/1, I’m already thinking “this could be :none”, and when I call Result.unwrap_or/2, I’m forced to provide a default value. If I use Result.unwrap!/1, I’m reminded that it could raise.

So really, if I forget to unwrap a Result, I’m equally likely to get:

** (BadMapError) expected a map, got: {:ok, %{}}
# or
** (BadMapError) expected a map, got: :none

Either way, personally I find it pretty easy to see that I just forgot to unwrap the Result. And unwrapping it will force me to specify a default value, or raise.

On the other hand, if I get: ** (BadMapError) expected a map, got: nil, I get a sinking feeling, because it’s not clear I just forgot to unwrap a Result: it could equally well mean that the code runs fine 99% of the time when the value is usually a map, and this one time it was unexpectedly nil due to a race condition :roll_eyes:

1 Like

This addresses a real desire for me having gotten used to Option/Result types with Rust and Elm. The discussion in this thread has shown how much thought you put into the API design. Thanks for putting this out!

2 Likes

Thank you for your exhausting and clarifying post.

I do think :ok makes sense in side-effect-only functions, e.g. logging, assertions.

I agree

My design philosophy for the library was to accept as much as possible as input, though. Functions will not crash even if the input is not a tagged tuple.

I understood it from your previous posts. But in my/our case (with iodevs/Result) mostly we put :some (with value) to the map (or list) and then call Result.map(), Result.and_then(), etc… according to need.

So using iodevs/Result library you can write, e.g.

user_id
|> User.get_user()           # do_query fun, return Result.t(String.t(), User.t())
|> Result.map(&User.do_something(&1,  user_anything)  # Result.t()
|> ExMaybe.from_result()     # data or nil in case of error
|> ExMaybe.with_default(User.default_settings())      # will handle nil at _any_ point

Or check our QR_code library for how we used iodevs/Result. The main core of iodevs/Result (and of course iodevs/ExMaybe) was strongly inspired by Elm Result type.

if I forget to unwrap a Result, I’m equally likely to get:

I get your point, but in error messages in your terminal/logs/… you also have the name of modules and functions which were called and of course the number of line in your ex file where error happened.

Something like

lib/module_name.ex:60:  ModuleName.do_update...
** (BadMapError) expected a map, got: nil

So you exactly know where you have to look at.

1 Like

Sure, but our brain gets used to tune out the noise very quickly. I get your point but I am 50/50 about if it’s an issue that could get annoying with time.

Not sure I follow, can you give an example? I’d think that if you adopt a model or having more metadata in your result values then you’d make sure that all your project’s code is using them and then convert them at the edges of the outside world (internet) and your dependencies. Fairly standard deal when you decide to buy into a library’s value proposition.

Thanks; I really appreciate you taking the time to say that :slight_smile: