Hacker News new | ask | show | jobs
by benmmurphy 2547 days ago
coming from erlang elixir is much nicer than that. exunit is miles ahead of eunit and i feel like that would be enough to convince me to switch.

the with syntax is nice and helps to deal with early exit on errors but still feels a bit awkward compared to imperative control flow.

like you can do something like this in an imperative language:

    foo, err = func()
    if err != nil {
      return nil, Err("bad")
    }
    bah, err = func2(foo)
    if err != nil {
      return nil, Err("blah")
    }
    return bah
whereas in elixir you have to do something like the following in order to avoid the nesting of doom:

    with {ok, foo} <- func() |> Error.or({:error, "bad"}),
         {ok, bah} <- func2(foo) |> Error.or({:error, "blah"}), do
      bah
    end
like the with blocks kind of work until you have to transform the errors you receive then you need helper functions. if you are just doing static transforms like above it is not too bad but it starts to get hairy if you want to transform based on the returned error or variables in the function. whereas the imperative style you can inline your error handling logic nicely into the function.

for example what if i want to log something on the error path that includes some contextual information and custom formatting. probably, the easiest way is going to be to use with() and pipe to a custom function that triggers on the error path to do the logging because the code is going to start getting really messy. whereas if i was writing it imperatively i could just inline the logging statement most of the time because it is just a few lines of code.

    foo, err = func()
    if err != nil {
      return nil, Err("bad")
    }
    bah, err = func2(foo)
    if err != nil {
      Logger.error("err: " + err + " when processing: " + foo.name)
      return nil, Err("blah")
    }
    return bah
like i feel this is a bit messy but maybe it is actually not that bad:

    with {ok, foo} <- func() |> Error.or({:error, "bad"}),
         {ok, bah} <- func2(foo) |> Error.or_else(fn err ->
           Logger.error("err: " <> err <> " when processing: " <> foo.name)
           {:error, "blah"} 
         end), do
      bah
    end
4 comments

The idiomatic way to do that is to match errors in the else branch of the with statement.

I've been using Elixir and Phoenix for a customer for a couple of years. It's ok to great, especially when spawning jobs, with some stains.

I'm not a great fan of the with syntax. I wish they implemented it as a native statement of the language instead of as a macro. In that way they probably could let us write the same code inside and outside a with, instead of having to transform = into <- and add a comma.

But the worst offenders are GenServers. They should really have the syntax of OO classes instead of the incomprehensible handle* functions. After all that's what they are, objects with their own CPU. (Remember Armstrong about Erlang being the only true OO language?)

By the way, that would make it easier to code, to understand and to migrate people from imperative languages.

I think one of the big points about being a true OO language is that the only way to talk to a process is via message passing. To me, the handle concept makes sense when you consider that it's handling a message in its mailbox.
Yes, I see that but it's an unnecessary complication. All I want to do client side is calling Module.func() and GenServer side I wish I could only def func() instead of all those incantations.

Furthermore GenServers mix client side and server side code in the same module. It's very confusing even after years. It's one of the most unpleasant coding experience of the decade for me.

On the other side I'd steal Elixir's pattern matching implementation and add it to every language. It's everywhere in Elixir and IMHO it's its strongest point.

I think Dave Thomas has a bunch of comments on the elixir forums, and perhaps blog articles and videos too that express a similar problem with GenServers. might be interesting to look those up.

IIRC, one approach he encourages is to at the very least keep most of your logic in a separate module rather than have just a GenServer module that contains a bunch of regular functions and a bunch of handle_<x> functions (which is kind of idiomatic, or at least what I've always been taught).

That said, it's been a while since I followed this discussion so I'd be happy to be corrected.

EDIT: calling a 'public' function in a GenServer and having a handle_<x> function actually deal with it does seem to have an explicitness about it that I think I like. Learning about processes and how they work, I imagine I might've gotten confused if a seemingly normal functional call somehow magically handed things off to a different process.

Yeah you may be on to something here. Looking at GenServer callbacks as some sort of pure independent functions would not make a lot of sense, they're tightly coupled to processes/GenServer anyway.
You need to do it this way, it's more idiomatic:

    with {:user_created?, {:ok, user}} <- create_user do
      # do something with `user`.
    else
      {:user_created?, {:error, errors}} -> 
        # something
      _ ->
        # some unhandled error
    end
And so on, use an atom on the fly to identify branches and error conditions. But honestly most of the time you worry about happy paths and Let It Crash.
yeah this is kind of horrible once you get to high levels of nesting. in an imperative language you can return early in the error paths and your function will still be understandable. this doesn't work with a single return. you can easily have 6 guard statements in an imperative language and your function will be comprehensible try doing 6 levels of nesting in elixir and it will be a complete mess.
Maybe the “OK” library [1] could be useful for you.

I would also welcome a better built-in idiom for early returns.

[1] https://hexdocs.pm/ok/readme.html

You also have to consider that the whole BEAM philosophy is "let it crash".

On a "normal" language you have to deal with all error paths or your app goes down. On the BEAM you handle the happy case and common and unexceptional errors. Otherwise, let it crash.

Example: if your app depends on a database that once in a blue moon is unreachable, don't test for connection errors. Just assume the connection was successful, and if it wasn't, most of the time once the process restarts everything is fine.

I have an Elixir app in production, I get Sentry errors once in a while about some weird state outside my control (we work with external APIs) that caused a process to crash, I just ignore them and go on with my life.

EDIT: I highly recommend this video from Saša Jurić: https://www.youtube.com/watch?v=JvBT4XBdoUE

I'm a big fan of the 'let it crash' approach. A lot of code I write is hacker-ish stuff where I just want to get something done and I don't need it to be pretty. I've been amazed at how many of my projects in Elixir just kind of keep chugging along despite the fact that I mostly bothered coding only the 'happy path'.

A while ago I wrote some code that retrieves data from various online sources (cryptocurrency exchanges) at regular intervals. A friend of mine wrote essentially the same code in Python. His code was littered with try/catch statements and even with those the app would often crash because these API's sucked ass. As a result, there'd be gaps in the data, or he'd have to restart the app or set up whatever restart mechanism one uses in the python ecosystem.

Meanwhile my elixir project just kind of kept running and retrieving data, and barring permanent changes in the various API's, it would keep collecting data even in every once in a while the endpoints would misbehave.

My typical approach to projects is to tinker and cobble things together but then also overly worry about all the things that can go wrong. I know I'm not alone in being this way; I've met plenty of developers who seem to be a combination of just wanting to get results but then worrying excessively about everything that could go wrong.

Elixir is singular in how it allows me to approach projects in this manner while still feeling confident that it'll work well enough. And while that might not be the most common use-case, it's really helped reinvigorate my enjoyment of programming.

Not sure I understand completely your problem and I apologize if this is a stupid question, but can’t you just pattern match the errors in with’s else block?
this depends on errors being unique to the line they come from. if any of your errors overlap then it doesn't work. for example what if you have a bunch of functions that return {error, :notfound} and you want to handle each differently. also you don't have access to any previous values instantiated in the else block. if you could have an else block for each <- that short-circuited and had access to previously <- assigned values it would be perfect.
One technique I use to solve this is to name each stage of a complex pipeline with tuple pairs. Then I can pattern match on a specific error. I've found that I haven't ever written crazy error handling and so this has worked well for me
Yeah, if your functions return the same error it's quite difficult to use the "else" pattern. I also dislike the fact that the error handling is not near the function that provokes it, but in a separate block, so you have to remember which function in the pipeline returns which error. I've never written a with-pipeline with more than 4-5 calls, so it's been easy to avoid its limitations, but I can understand it's not optimal if you're writing complex stuff.