Hacker News new | ask | show | jobs
by jammycakes 1034 days ago
The author followed up this post with another one a few years later titled "Against Railway Oriented Programming":

https://fsharpforfunandprofit.com/posts/against-railway-orie...

Railway-oriented programming is an interesting concept and it does have its use cases, but it does need to come with a massive health warning. I've often seen it used in practice to reinvent exception handling badly, and this is something I consider particularly ill advised because exceptions, when understood and used correctly, provide a much cleaner and more effective way of handling error conditions in most cases.

The thing about exceptions is that in most cases, they make the safe option the default. An error condition is an indication that your code can not do what its specification says that it does, and in that case you need to stop what you are doing, because to continue regardless means that your code will be operating under assumptions that are incorrect, potentially corrupting data. Error conditions can happen for a wide variety of reasons, many of which you do not anticipate and can not plan for, and in those cases the only safe option is to clean up if necessary and then propagate the error up to the caller. Exceptions do this automatically for you by default (you need to explicitly override it with a try/catch block) but alternative approaches, such as railway oriented programming, require you to add in a whole lot of extra boilerplate code that is easy to forget and easy to get wrong. If you can't handle the error condition on the way up the call stack, you would then log it at the top level and report a generic error to the user.

Having said that I see two particular use cases for this kind of technique. The first is situations where you need to handle specific, well defined and anticipated errors right at the point at which they occur. Validation is one example that comes to mind; another example is where you are trying to fetch a file or database record that does not exist. The second is situations where exception handling is not available for whatever reason. Asynchronous code using promises (for example with jQuery) are pretty much an exact implementation of railway oriented programming, but since modern JavaScript now has async/await, we can now use exception handling in these scenarios.

7 comments

> Exceptions do this automatically for you by default (you need to explicitly override it with a try/catch block) but alternative approaches, such as railway oriented programming, require you to add in a whole lot of extra boilerplate code that is easy to forget and easy to get wrong.

The unfortunately missing part of exceptions (in mainstream languages) is that they handle this invisibly. Figuring out, at compile time, what sort of exceptions can appear inside a given function is not obvious.

That's the big payoff of ROP: you can look at any function signature and immediately know what sort of errors can come out of it.

Mitigating the downside of ROP (boilerplate) can be done to various extents, depending on the language. Haskell has do-notation. In F#, using the result computation expression [0] can make your code extremely clean:

    type LoginError = InvalidUser | InvalidPwd | Unauthorized of AuthError

    let login (username : string) (password : string) : Result<AuthToken, LoginError> =
      result {
        // requireSome unwraps a Some value or gives the specified error if None
        let! user = username |> tryGetUser |> Result.requireSome InvalidUser

        // requireTrue gives the specified error if false
        do! user |> isPwdValid password |> Result.requireTrue InvalidPwd

        // Error value is wrapped/transformed (Unauthorized has signature AuthError -> LoginError)
        do! user |> authorize |> Result.mapError Unauthorized

        return user |> createAuthToken
      }
Could we do the reverse, i.e. mitigate the downside of exceptions? Is there a linter, code analyzer, or some other compile-time tool that can integrate with a Java IDE and automatically display the uncaught exceptions that might be thrown by a given line of code?

[0] https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoo...

Java has/had a compiler check that forced you to write catch blocks or `throws` annotations in/on functions that call other functions which might throw. The feature is called "checked exceptions" and I believe it has been discarded for its inconvenience by now.

Sometimes it feels like developers are going in circles while trying to find the most optimal way to handle errors.

Checked exceptions are one of the main reasons I’m sticking with Java, even though Java lacks the ability to abstract over sets of checked exceptions, which does cause some inconvenience. It’s unfortunate that no other mainstream languages have been taking that approach.
In languages with a Result type (or Either monad), you basically do have checked exceptions, even though the language has no exceptions.
With result types, you typically don’t get automatic exception propagation. I agree that overall it’s a spectrum of syntactic convenience, checked exceptions effectively form a sum type together with the regular return type.
Java also has that option. There is an excellent library called vavr. It adds the Try and Either monad to Java.
That sounds awesome! Do you have that flag set on a big codebase? Was it a big hassle to turn it on (like you had to remediate a bunch of code that didn't handle exceptions before you could check it in). Have you seen any big changes since enabling it?
Checked exceptions in Java are the default state and much of the standard library uses them.
People are now realizing that having the errors a function can cause right in the type system may actually have been a good idea, but when you point out that Result is not the only way and that Java checked Exceptions do the exact same thing (and so does the Zig error handling mechanism which is a third variant of the idea), they come up with all sorts of easily dismissable nonsense to explain why the two are very different.
What would you reply to this comment:

https://news.ycombinator.com/item?id=37174698

Well, that's kind of true! The fact checked Exceptions are inconvenient doesn't change the fact they are equivalent to returning a Result type (the implementation is obviously different but I think we don't need to mention that).

A future version of Java could totally make it more convenient, and perhaps even make the implementation cheaper such that it would not just nearly the same , but literally the same as in Rust or other similar languages.

Does it even need a new language version? If the compiler already spits out the error "hey, your Fart() function should be annotated with 'throws ButtsException'", couldn't an IDE relatively easily be configured to automatically add the " throws " annotations?
I’m not the parent, but exception declarations are IMO necessary for a stable API contract. It’s exactly the same reason why return types are explicit. The actual issue in Java is that you can’t abstract over an arbitrary-length list (sum) of checked-exception types (variadic type parameters) (with the exception of rethrowing from multi-catch clauses).
After working with railway oriented programming with Arrow in Kotlin I had the same feeling of using checked exceptions.

And please make the Errors generically wrappable in order to avoid losing the traces.

> The unfortunately missing part of exceptions (in mainstream languages) is that they handle this invisibly. Figuring out, at compile time, what sort of exceptions can appear inside a given function is not obvious.

Figuring out, at compile time, what sort of exceptions appear inside a given function is a futile exercise in many contexts, and railway oriented programming does not fix it. Java tried this with checked exceptions and it fell out of favour because it became too unwieldy to manage properly.

In any significantly complex codebase, the number of possible failure modes can be significant, many of them are ones that you do not anticipate, and of those that you can anticipate, many of them are ones that you cannot meaningfully handle there and then on the spot. In these cases, the only thing that you can reasonably do is propagate the error condition up the call stack, performing any cleanup necessary on the way out.

"Handling this invisibly" is also known as "convention over configuration." In languages that use exceptions, everyone understands that this is what is going on and adjusts their assumptions accordingly.

> Java tried this with checked exceptions and it fell out of favour because it became too unwieldy to manage properly.

Because they did a half-assed job of it, and required the user to explicitly propagate error signatures. Inference and exception polymorphism are essential.

Checked exceptions always seemed to me to be an exercise of self-flagellation and enumerating badness; when most of the time there are a handful of specific errors that require special handling, with everything else logged/return error/possibly crash.
The problem is that the callee can’t decide for the caller which exceptions will require special handling. And for the caller to be able to make an informed decision about that, the possible exceptions need to be documented. Since this includes exceptions thrown from further down the call stack, checked exceptions are about the only practical way to ensure that all possible failure modes get documented, so that callers are able to properly take them into account in their program logic.
If you want to (and are able to) document all possible failure modes, then checked exceptions will give you that. As far as I can tell, railway oriented approaches can't.

Unfortunately, you can only do that when the number of possible failure modes is fairly limited. In a complex codebase with lots of different layers, lots of different third party components, and lots of different abstractions and adapters, it can quickly become pretty unwieldy. And then you end up with someone or other deciding to take the easy way out and declaring their method as "throws Exception" which kind of defeats the purpose.

No; you simply abstract the underlying subsystem’s exceptions in your own types, the same way you do with any other type.

And yes, “railway oriented approaches” can absolutely do this.

You adjust the reported failure modes to the abstraction level of the respective function, wrapping underlying exceptions if necessary. You don’t leak implementation details via the exception types. Callers can still unwrap and inspect the underlying original exceptions if they want, but their types won’t typically be part of the function’s interface contract, similar to how specific subtypes of the declared exception types are usually not part of the contract.
I think the conventional way exceptions are implemented is pretty bad.

First, a lot of languages make you use an awkward, unnecessary scope to catch an exception. e.g., you want to declare and initialize a variable to the value of a function that can throw (and assign some other value if it does. Well, you've got to split the declaration and initialization, putting the declaration outside the scopes try and catch create. That one's an unforced error -- languages don't have to do that to use exceptions, but for some reason many do. It's pretty weird to have to add homespun utilities for fundamental control flow scenarios.

But the bigger issue is that you really want to handle the error conditions at the lowest level where you have enough context to do so correctly. That's usually pretty low, but exceptions default to "send it all the way to the top". The default is either invisible or invisible in practice, depending on the language, and wrong, so programs end up riddled with these issues. You tend to end up with these higher-level functions that can throw all kinds of exceptions, many of which are meaningless to the caller. E.g. someone adds a file cache one day and all of a sudden some higher-level HandleRequest function can through a IO exception... because the cache code didn't handle it... because they never even realized it was a possibility. You couldn't design a better mechanism for creating leaky abstractions.

I think anything a function might return needs to be an explicit part of its signature, and a caller needs to handle it explicitly, even if just to indicate, pass it up the line. The langue doesn't need to require a lot of boilerplate to do this.

That's just my experience from having lived through it.

If you catch them at the lowest level you end with N different exceptions that are coupled with implementation.

I only need different exceptions if I am treating them different.

Oh! And you typically catch it next to the failure ( maybe wrapping it in another exception) out in a global exception handler

I think Rust has shown very well how ROP with first-class syntax support pretty much eliminates all boilerplate code. IMHO Rust nailed error handling with the `Result` type/trait.
It came to my mind too but then I got confused, what if the type of the Result changes along the function call chain and you want to propagate Errors with minimal effort?

Then I saw this stackoverflow question and it seems that ? operator does quite smart thing and is as easy to use as possible.

https://stackoverflow.com/questions/31172451/is-there-a-non-...

The Rust community is converging on the anyhow crate to chain multiple error types without having to enumerate each.
this is a gross oversimplification

anyhow is the most commonly used crate to have type erased errors(1), nothing more then that but also nothing less

this means when returned form a library a Result<_, anyhow::Error> _is often an anti-pattern_ (often not always!)

but if you write an application it's pretty common to have many many places in the code where you can be sure that no upstream code needs more fine grained error handling (because you workspace is the most upstream code) so using anyhow is a pretty common and convenient choice

Though it's not unlikely for anyhow to fade into being mostly unused in the future with further currently missing rustc/std features, through not anytime soon.

But luckily this doesn't matter, due to how `?` works you can trivially convert errors on the fly no matter which (well kinda, there is an unlucky overlap between orphan rules and From wildcard implementations in the anyhow crate, but we can ignore that for this discussion).

(1): It's basically a form of Box<dyn Error + Send + Sync + 'static> which also has thin pointer optimizations and (can) by default include a stack trace + some convenience methods.

Sure, but the question was specifically looking for the "minimum effort" solution. I almost brought up thiserror but that just makes things more complicated. If you're writing a Rust application and just want to propagate errors, anyhow is currently the most popular way to do that.
It looks like thiserror (6.6M downloads/month) is more popular even though anyhow (5.7M downloads/month) is listed as #1 https://lib.rs/keywords/error
they are not competing

they handle two different cases

anyhow is for type erased errors, which is mainly used for the kind of errors you mainly propagate upward without handling them in any fine grained way. It's mainly used in applications (instead of libraries). For example in a web server anyhow errors will likely yield Internal Serer errors.

thiserror provides a derive (codegen) to easily create your own error. It's much more often used by libraries, but if an application doesn't want to handle this errors they will likely be converted into anyhow errors. A very common use case is to apply it on an enum which represent "one of many errors" e.g. as a dump example `enum Error { BadArgument(...), ConstraintViolation(...), ... }` and it's no absurd in some cases to have a mixture e.g. an enum variant `Unexpected(anyhow::Error)` which represents various very unpexted errors which likely could be bugs and you might have considered panicing there but decided to propagate them instead to avoid panic related problems

+1 this

I don't understand why this answer is buried deep in a thread & isn't included in the Rust Book, even though it's been conventional wisdom among experienced Rustaceans for a few years now.

thiserror should be in the stdlib frankly. Or Rust should offer its facilities natively.
Download counts don't mean very much here as I'm fairly sure both crates are common transitive dependencies. Or in other words, millions of programmers aren't individually choosing Anyhow or Thiserror on a monthly basis -- they're just dependencies of other rust crates or apps.

And agreeing with the other reply, nobody jumps up and down with joy when choosing an error handling crate. You pick the right poison for the job and try not to shed a tear for code beauty as you add in error handling.

In my mind, the difference between errors-as-values and exceptions is most useful when describing domain-specific errors and other issues that you have to handle in support of the domain/problem space. To me, domain errors make sense as errors-as-values, but your database being unreachable is unrelated to the domain and makes sense as an exception.

> another example is where you are trying to fetch a file or database record that does not exist

I think this depends on whether or not you expect the file/record to exist. Handling a request from a user where the user provided the id used for lookup? The lookup itself is validation of the user input. But if you retrieved a DB record that has a blob name associated with it and your blob storage says that a blob doesn't exist by that name? I find that to be a great situation for an exception.

The errors-or-exception line is fuzzy and going to be dependent on your team and the problems you're solving, but I've found that it's a decent rule of thumb.

"The first is situations where you need to handle specific, well defined and anticipated errors right at the point at which they occur"

Barring system level errors can you give an example of an error state that's not like that, that would then rather merit an exception? I would like to understand your point of view, is it due to the nature of the problem, or the constraints of runtime that make exceptions preferable.

In the C++ code I need to write, we can 1. check data for error conditions in the beginning 2. if we fail the error check, let application crash 3. use the found error state to debug and fix the error in the initial checking code.

The data my code needs to process is fairly straightforward - data abiding by some known CAD data format or given geometric topology, so the error conditions are "quite easy" to tackle in the sense that there is an understanding what correct data looks like in the first place.

Missing dependencies. External services having gone offline. Timeouts. Foreign key violations. Data corruption. Invalid user input. Incorrect assumptions about how a third party library works. Incorrectly configured firewalls. Bugs in your code. Subtle incompatibilities between libraries, frameworks or protocols. Botched deployments. Hacking attacks. The list is endless.

Probably not so much of an issue if you're dealing with well validated CAD data and most of your processing is in-memory using your own code. But if you're working with enterprise applications talking to each other via microservices written by different teams with different levels of competence, legacy code (sometimes spanning back decades), complex and poorly documented third party libraries and frameworks, design decisions that are more political than technical, and so on and so forth, it can quickly mount up.

External services having gone offline, timeouts, and invalid user input are expected conditions you should handle locally.

Almost everything else you listed represents a bug in your software that should terminate execution.

I’m more than a little shocked that you think yeeting exceptions up the call stack is appropriate for these cases.

> External services having gone offline, timeouts, and invalid user input are expected conditions you should handle locally.

Not necessarily. You should only handle expected conditions locally if there is a specific action that you need to take in response to them -- for example, correcting the condition that caused the error, retrying, falling back to an alternative, or cleaning up before reporting failure. Even if you do know what all the different failure modes are, you will only need to do this in a minority of cases, and those will be determined by your user stories, your acceptance criteria, your business priorities and your budgetary constraints. That is what I mean by "expected conditions." Ones that are (or that in theory could be) called out on your Jira tickets or your specification documents.

For anything else, the correct course of action is to assume that your own method is not able to fulfil its contract and to report that particular fact to its caller. Which is what "yeeting exceptions up the call stack" actually does.

> Almost everything else you listed represents a bug in your software that should terminate execution.

Well of course it represents a bug in your software, but you most certainly do not terminate execution altogether. You perform any cleanup that may be necessary, you record an event in your error log, and you show a generic error message to whoever needs to know about it, whether that be the end user or your support team.

Again, what action you need to do in these cases will depend on your user stories, your acceptance criteria, your business priorities and your budgetary constraints. But it is usually done right at the top level of your code in a single location. That is why "yeeting exceptions up the call stack" is appropriate for these cases.

You only terminate execution altogether if your process is so deeply diseased that for it to continue would cause even more damage. For example, memory corruption or failures of safety-critical systems.

> I’m more than a little shocked that you think yeeting exceptions up the call stack is appropriate for these cases.

I hope I've clarified what "yeeting exceptions up the call stack" actually does.

The alternative to "yeeting exceptions up the call stack" when you don't have any specific cleanup or corrective action that you can do is to continue execution regardless. This is almost never the correct thing to do as it means your code is running under assumptions that are incorrect. And that is a recipe for data corruption and all sorts of other nasties.

How do you know what to cleanup when you have no idea which APIs might throw, what stack frames might have been skipped when they do throw, and what state was left broken by yeeting a stack-unwinding exception up your call stack?
You clean up processing that your own method is responsible for. For example, rolling back transactions that it has started, deleting temporary files that it has created, closing handles that it has opened, and so on and so forth. You rarely if ever need to know what kind of exception was thrown or why in order to do that.

You can only assume that the methods you have called have left their own work in a consistent state despite having thrown an exception. If they haven't, then they themselves have bugs and the appropriate cleanup code needs to be added there. Or, if it's a third party library, you should file a bug report or pull request with their maintainers.

You don't try to clean up other people's work for them. That would just cause confusion and result in messy, tightly coupled code that is hard to understand and reason about.

C++/Rust are different because exceptions in those languages are expensive and culturally counter indicated.

For the runtime-hosted languages the author is talking about (JVM, CLR, Python etc.), optionally throwing an exception is much cheaper than constantly creating and unwrapping Result objects. Your example is a perfect case where one would prefer to throw: say you have a parser that parses your file and the parser is expensive because the files are large. You are better off throwing out of your parsing iteration then doing a Result.map in your hot loop. (However you might want to wrap the top level of the parser in a Result and return that.)

> An error condition is an indication that your code can not do what its specification says that it does

Meanwhile, in real code-bases, exceptions are used for all sorts of expected but less common situations, like ProductNotFoundException

I disagree that exceptions are better in most cases. Exceptions aren't captured effectively in most type systems so it's hard to ensure you've covered all your bases. When used effectively, discriminated unions for return types force you to handle all the cases and the result is much more robust in my experience.