Usage of {:ok, result} / :error vs {:some, result} / :none

Rust has a very nice semantics for Option<T> and Result<T,E> These two have similar uses but have a clear distinction about expectations. Option is used when the returned value can be Some<T> or None whereas Result<T,E> is used when the returned value can be an Ok(T) or an Err(E). In Erlang and Elixir we usually see a return value of {:ok, result} or an {:error, error}, this makes sense when there are 2 possible outcomes success and a failure. However, in some cases we expect value to be present or absent, in those cases a tuple containing {:some, result} or a an atom :none seems more expressive. However there is also the possiblity of using result and nil if the result is not present. Using {:some, result} / :none will make the receiver handle both the scenarios and seems like a good way to write code. However, I haven’t seen a lot of elixir code which does this or a variation of this. I’d love to hear how you guys write these type of functions.

2 Likes

I agree, but I don’t know that this needs to be an either/or kind of decision. What about:

{:ok, result}
:none
{:error, error}

The with/do/else pattern I feel encourages more readable response atoms. I wound up with this recently:

with(
      client_ip = get_client_ip(request_ip, params),
      {:ok, location} <- Geoip.lookup_ip(client_ip),
      true <- should_search?(location, parse_exclusion_zone(params)),
      query = build_search_query(%SearchQuery{}, location, params),
      {:ok, results} <- NHSearch.search(query),
      Analytics.track(results, client_ip, "api", "search_results")
) do
      json conn, results
else
  {:error, :location_not_found} ->
    Logger.error("Unable to find the location for: #{get_client_ip(request_ip, params)}")
    # Need to make this an error message of some sort to explain what's going on.
    json conn, []
  :user_in_exclusion_zone ->
    Logger.info("User is too close to the exclusion box")
    # Need to make this an error message of some sort to explain what's going on.
    json conn, []
  other ->
    Logger.error(inspect(other))
    json conn, []
end

should_search could have just returned false, but with the with() pattern, I instead returned :user_in_exclusion_zone to make the code less brittle and easier to read.

2 Likes

The Erlang ecosystem (and thus much of Elixir’s) uses:

{:ok, result} / :error
– or –
{:ok, result} / {:error, reason}
– or –
:ok / {:error, reason}
– or –
:ok / :error

Depending on what information needs to be returned. Consequently I’m a fan of the Exceptional Elixir Package as it wraps all of the above, and exceptions, and other things all into a single interface that is wonderfully easy to pipe around. ^.^

9 Likes

I’ve also seen code where people use [result] if there is a result, and [] if there is none.
Lists are of course a more general way of Rust’s Option<T> or Haskell’s Maybe type, as besides no result and one result, they might also contain two, three, etc. results.

2 Likes

This is exactly what I’ve done in a library I’m working on. It certainly works well and expresses all the scenarios, but :none isn’t very idiomatic, is it?

What about this alternative?

{:ok, {result}}
{:ok, {}}
{:error, error}

I also question whether nesting result in a tuple this way or passing around {} are idiomatic.

2 Likes

The way to indicate a successful status for something that isn’t expected to return an actual result would normally be simply :ok. If one or more results are expected, but can be empty, then {:ok, []} / {:ok, [some, results]}.

If it’s something like initializing a connection or whatnot, I’d expect {:ok, the_thing} or {:error, :because_reasons}.

1 Like

What if exactly zero or one results are expected and errors are possible? Using a result list works, but doesn’t convey there can be no more than one result.

Maybe using a list even when you don’t anticipate ever needing to return multiple results is wise because it:

  1. encourages callers to handle the possibility and
  2. gives you the option to return more results in the future
1 Like

Hmm… well, not sure actually - do you have an example? I’ve mostly never been in that situation :slight_smile:

Unless you mean things like eg. looking up a key in a KV store; then I’d expect one or more of a couple of different interfaces to be available:

  • give me a value, or give me death! (eg; if key is found, return value… else, raise exception)
  • give me a value, or an error (eg; {:error, :not_found})
  • give me a value, or some default (that you specify, but a reasonable preselected one is nil)

I’d use the option that suits my particular situation.

1 Like

A lookup function to a KV store was exactly what I was about to suggest.

Having the caller provide a default with a fallback to nil is pretty good, but not perfect. If the KV store can have any arbitrary term as a value you don’t know in advance whether nil or any other value is guaranteed to not be returned as a positive result. If I call get(kv_store, k) and get nil does that mean the KV explicitly maps k to nil or there is no key k yet? To know for sure I’d have to call get(kv_store, k, :another_default) and check whether I still get nil or :another_default.

For that reason, I think it’s best to wrap the positive result somehow. A list is one option, but again it doesn’t convey that there can’t be multiple values associated with a single key.

1 Like

You wouldn’t get nil in that case - unless it was the value of that key :slight_smile: But I’d only ever use the call w/ a default value if I needed a value, and wanted it to be eg. 0, or something else, if the key wasn’t registered.

If I just needed to act differently in case of a missing key, I’d simply use the part of the API that returns {:ok, value} / {:error, :not_found} (or… {:ok, value} / :error like Map does).

Thinking about it, for this type of situation I’d call the API broken if it didn’t at least have those two options (default value that I can specify for missing keys or ok / error responses).

1 Like

Ah, you beat me. Here’s the edit I posted around the same time:

Reading your post again, I realize the not found case was classified as an error using {:error, :not_found}. That’s also a good solution, but it’s a fairly common case for a key to not have a value. So far I’ve preferred saving :error and {:error, reason} for truly problematic situations.

1 Like

Hehe, yep… saw that afterwards :slight_smile:

But to clarify… the way I see it, a proper API would ideally implement all three of these options, letting me choose which one to use depending on what I need.

As for reserving :error for more serious problems… that’s why you have a reason along with it :wink: If you start venturing outside of these conventions, you do risk making life just a little bit harder for those who are experienced with the existing Elixir standard library, or Erlang for that matter.

Of course new ways of doing things are worth exploring, but I think you’d need to prove some fairly substantial benefit for the ecosystem at large to follow suite.

2 Likes

This whole time I’ve been thinking about Map.get/3, but when you mentioned Map returning :error I took a closer look and noticed fetch. Providing a variety of options makes a lot of sense.

Completely agreed. I’ve dabbled with Elixir/Erlang on and off over the years, but I’m still learning what an idiomatic API looks like. I enjoy exploring options, but ultimately I want to learn what’s idiomatic and why. Thanks for your help.

I’m happy we’ve got a forum where we can hash these things out. Thanks @minhajuddin for asking about this–I was wondering the same thing. And thanks to everyone else for chiming in :). This is a great community.

2 Likes

Or you can use something like Exceptional that I linked above, which can normalize all of those representations into a singular, easily-pipeable version. :slight_smile:

1 Like

Yep… Have yet to try it myself, but that’s more a client concern though, if I understand it correctly? :slight_smile:

1 Like

Eyup, it lets you handle errors from any api whatever they may do. It also encourages you to return an API of the form of result | %YourException{}, do note that is returning your result straight if successful or returning, not raising an exception on failure, which is a bit of an oddity in the Elixir world, but Elixir’s exception structures contain everything needed to know about errors so it is a wonderfully useful way to return an error information, and by returning it and not throwing it then you do not incur the large performance cost from raising exceptions. :slight_smile:

1 Like

Seems like it might be a bit awkward to match on if you’re not using Exceptional… Especially if structs could be returned during normal operations too :sunglasses:

1 Like

In the Elixir built-in Exception(s?) module it has helpers for testing if something is an exception. :slight_smile:

1 Like

Looking at lot of the messages in this thread, it’s clear that depending on the types of data being returned, there isn’t really a single right answer for return formats. It depends on the type of data you’re returning, and your problem domain. If I am searching for a product on amazon and nothing matches, then {:ok, []} makes sense. If the search failed because the database was down, then {:error, :database_down} would make more sense.

A lot of functions will also return their value, which implicitly means :ok. This makes your code easier to pipe around.

I’m playing with Exceptional. I do like the idea of using exception structs as return values, because structs aren’t positional the way tuples are. Definitely that feels like a better answer. However, I haven’t found a case where I feel it improves on the with/do/else syntax. I tried to recreate my earlier example with it, and it came out the same, aside from my ‘else’ block matching maps instead of tuples. So far, I’m just not sure it’s worth breaking with idiomatic elixir for that.

@OvermindDL1 - I’m not a classically trained functional programmer. Am I missing something?

2 Likes

I think the important thing is that irrespective of exactly which alternative is chosen that it is easy to pattern match on. So when you do want to test the alternatives you can just use case

case some_function(1) do
  {:ok,val} -> do_yes(val)
  {:error,err} -> do_no(err)
end

This is possible in most cases but what is also very practical that you can just match against the success case else have the system generate an exception if no match:

{:ok,val} = some_function(1)

This case is often forgotten which can complicate its usage.

7 Likes