OK - elegant error handling with result monads (alternative to Elixir `with` special form)

Experimenting with this code.

OK.try do
  user <- fetch_user(1)
  cart <- fetch_cart(1)
  order = checkout(cart, user)
  save_order(order)
end

Ok.with/1 supports an else block that can be used for handling error values.

OK.with do
  a <- safe_div(8, 2)
  _ <- safe_div(a, 0)
else
  :zero_division -> # matches on reason
    {:ok, :inf}     # must return a new success or failure
end

The cart example above is equivalent to

with {:ok, user} <- fetch_user(1),
     {:ok, cart} <- fetch_cart(1),
     order = checkout(cart, user),
     {:ok, order_id} <- save_order(order)
do
   {:ok, order_id}
end

I have an implementation marked as beta as part of my OK project.

The Elixir with keyword has been very helpful for handling code with lots of branches, normally many error conditions. Such as the example above.

In the with example I find it is strange that the majority of my code my code lives in a list of arguments. It starts to get very lumpy if there are receive blocks or anonymous functions to get these values. Also in 90% of cases I am matching on an :ok tuple so donā€™t want to repeat that.

For these reasons I have been using the alternative macro from OK. It is far more restrictive because it will only match on :ok/:error tuples. However I like the restriction because it means that no matter how complex the block it will also only return :ok/:error tuples.
I am not sure that try is the best name. Alternatives that I am considering are

  • when, seams so make sense linguistically but already a keyword in elixir
  • with, familiar to elixir users
  • for, monadic for comprehension which is what this is but that might not be a very accessible term.
  • try, As is, however it doesnā€™t catch errors so the name could be misleading
6 Likes

Your syntax above has the same semantics as haskells do-notation in monads. This special case comes very close to the Maybe-implementation of a Monad and would look roughly like this (assuming magical IO, just working everywhere):

f = do
  user <- fetch_user 1
  cart <- fetch_cart 1
  let order = checkout cart user
  save_order order

Therefore Iā€™d go in fact for that and name that macro monadic. Your block would read as this:

OK.monadic do
  ...
end

The alternative names you mentioned at the end of the post are all four already keywords, top-level-macros or specialforms and have some meaning asigned to them. especially for implies some sort of "loop"ing, since it is heavily known from a plentora of imperative languages.

1 Like

I like OK.with because

  1. It letā€™s regular elixir users immediately grok that itā€™s like with.
  2. Itā€™s idiomatic English! ā€œIā€™m OK with that.ā€

(Try sounds like a try/catch/rescue replacement as opposed to a with replacement)

5 Likes

This is a great lib for playing with macros! That said, Iā€™m personally :thumbsdown: on efforts around alternatives to with, as I havenā€™t seen justified benefits.

Itā€™s worth pointing out that you should call out to functions in those cases. Also where you are seeing repetition, I am seeing explicit matching. The issues with Ok is that is hides the true match, and it gets more confusing if you were include a match within the 2nd elem, i.e. %{key: val} <- .... Is the rhs returning a tuple or map? It also breaks down the moment you want to match on something not in an :ok tuple. Using with, you simply add a new clause, with Ok you need to rewrite the entire block.

5 Likes

By that argument I would definetly go with for. For comprehensions are extensible to other monadic types such as the result monad which is what this is. See details of Scala for comprehensions to see more about that.[quote=ā€œchrismccord, post:4, topic:3264ā€]
The issues with Ok is that is hides the true match
[/quote]

That I consider a feature. The return type of functions used in this syntax must be a result tuple that is easy to describe. In many ways using the :ok tuple is just an implementation detail of a Result Monad. You could have an %OK{value: v}/%Error{reason: reason} struct instead and the behaviour would be the same. For that reason adding a pattern match on the left side is no more complicated.

%User{name: name} <- fetch_user(id)

The return value of fetch user is not ambiguous it is either a success with a user or an Error with single reason. Its far less ambiguous than with which can fail the match in any way. such as :error or false or nil or {:error, too, many, reasons

2 Likes

I definitely think that going for sensible English is the best way to decide. However because the ā€œdoā€ comes before the main actions I think when might be better. Also I want to add an else clause

ā€œIā€™m OK when doing x, y, z else logā€

OK.when do
  user <- fetch_user(1)
  cart <- fetch_cart(1)
  order = checkout(cart, user)
  save_order(order)
else
  error ->
    Logger.warn(error)
end

Note there is no else clause support, Yet.

1 Like

Exactly. Less noise, less typing, more descriptive. (I use with a lot, so this would be a big win for me - but definitely would need else matching.)

Yes, I think OK.when really captures the fact that itā€™s a tagged :okexpression and will return the {:ok, result} when the happy path is followed. So this is better than OK.with for that reason, but it would separate itself further from the standard with and might require more mental translation for new readers. On the whole though, I personally lean towards OK.when now.

Searching happy path brought me here :D, turns out, I was experimenting with topics like this some time ago, actually some of my first macro libraries are either similar to Ok or explore something along the lines.

  • ok_jose
    This one was my first macro library, I just wanted a result monad, you know for piping ok tagged tuples. But one of my main focus was not to introduce (nor override) existing |> elixir operators.
    Mostly like Ok and also allowed you to define other patterns besides :ok/:error tagged tuples.
    See the defpipe on the README, for example
@doc "Pipes a valid changeset or its ok tagged tuple"
defpipe pipe_valid_changeset do
   valid = %Ecto.Changeset{valid?: true) -> valid
   {:ok, record} -> record
end

# then you can pipe functions that expect valid changesets
{:ok, %User{}}
|> cast(params, [:email, :password])
|> validate_password_conformation()
|> Repo.create
|> new_user_token()
|> pipe_valid_changeset() # no new syntax, just this guy rewrites the pipe
  • happy
    Then for cases that were code was not that homogeneous (not always returning :ok/:error tagged tuples) I just wanted to avoid lots of nested case and ended up doing something along the lines of with (back before it landed in Elixir 1.2, which Iā€™ve been pretty much happy_with, except for its commas between match expressions)

  • pit
    This one is a bit weird (most things I do are), it was an experiment for having a with made for pipes. That is, you could specify a foo() |> pit(value <- pattern) |> bar() and let bar() take the value from the pattern matched result from foo.

Anyways, I guess itā€™s an interesting topic, and looks like for others too, since we have some people crafting this kind of things that do similar things (see the ones linked on Ok's README), if you find any other similar, Iā€™d be interested in looking at it. :slight_smile:

BTW, great work on Ok @Crowdhailer, Iā€™d go for OK.with since itā€™s closer to what with does and when reminds me of guards.

3 Likes

Thatā€™s a great point. That would add interference with new users I think and would be confusing. ā€œMy voteā€ is back to OK.with.

Itā€™s certainly an interesting discussion.

I managed to have a very long discussion about it more than a year ago on the elixir google groups.

For my way of thinking I think I have found the best solution. However itā€™s interesting to see how people have different priorities. I was deliberately strict in what I was working with so I could be terse, however others were far more driven by flexibility

1 Like

So another argument for using with in this form I have just discovered is the following.

Standard elixir with wonā€™t fail if you accidentally use = instead of <-.

I had the following code

with {:ok, user_id} = Map.fetch(body, "id"),
       {:ok, request_id} = Map.fetch(metadata, "request_id")
do
  # etc
else
  # handle
end

Both of those equals should have been <- but as I had not got a test for the failure case my mistake wasnā€™t spotted until later.

2 Likes

By ā€œin this formā€, do you mean using OK.with? If this is the case, Iā€™m not sure how it would be different, as you could also forget to use the <- in OK.with as well. But maybe Iā€™m just totally missing your meaning. Perhaps you could elaborate a little more?

1 Like

1.5 available on hex. Now has support for an else block that matches directly on errors.

OK.with do
  a <- safe_div(8, 2)
  _ <- safe_div(a, 0)
else
  :zero_division -> # matches on reason
    {:ok, :inf}     # must return a new success or failure
end
3 Likes

This is by design as you can have both ā€œhard matchesā€ with = and ā€œsoft matchesā€ with <-. Being able to mix them is important because forcing developers to use <- instead of = can be undesired in some circumstances. The opposite, which is the case you ran into, not so much since it will raise instead of silently passing.

3 Likes

Version 1.6.0 released, with two main improvements.

  • Much better errors, showing code snippet, expected and actual values

      Binding to variable failed, '{:bad, 6}' is not a result tuple.
    
      Code
        b <- bar(a)
    
      Expected signature
        bar(a) :: {:ok, b} | {:error, reason}
    
      Actual values
        bar(a) :: {:bad, 6}
    
  • required/2 to turn nilable values to result tuples

      maybe_port = Map.get(config, :port)
      {:ok, port} | {:error, :port_number_required} = OK.required(maybe_port, :port_number_required)
4 Likes

Version 1.8.0 released.

This release has proper for comprehensions. Have chosen to use the after keyword as the closest thing to yield

OK.for do
  a <- safe_div(8, 2)
  b <- safe_div(a, 2)
after
  a + b
end

# => {:ok, 6.0}
1 Like

Why not just use the last statement in the do block?

I thought the separation made things clearer? Your suggestion would certainly be possible

Everything in Elixir has the ā€˜last statement is the returnā€™ property, so separating it out seems really weird to me. ^.^;

OK.for do
a ā† safe_div(8, 2)
b ā† safe_div(a, 2)
after
a + b
end


To be hones, this doesn't make any sense to meā€¦ `do x <- 1 after x end` sounds like `x <- 1` should be done only after `x`ā€¦ Just as in `do x before x <- 1 end`ā€¦
1 Like