You can also add an else in the with so it looks like
with {:ok, fh} <- File.open(filename),
{:ok, contents} <- IO.read(fh)
do
contents
else
{:error, reason} -> File.close(fh)
{:error, reason} # this will return after the file has been closed
end
or maybe you would prefer to open the file, and pass that into the with and either way close the file. I just used IO as an example because they return nice {:ok, x} or {:error, reason} tuples, but this works with any pattern.The benefits over an exception is as the caller i can pattern match on the result of this. so i could have this is in a function in a case
result = case with_example(filename) do
{:ok, result} -> result
{:error, :some_reason} -> # this is recoverable, do something else
#log the issue
recover!(filename) # bangs in functions indicate they can fail and raise an error
{:error, :another_reason} -> # this is unrecoverable
# log the issue
raise "unrecoverable error"
_ -> # any other case we don't know about
raise "unexpected issue"
or i might not care and want it to crash if it doesn't match{:ok, parsed} = with_example(filename) # will raise a match error if {:error, reason is returned
Functional error handling types are much simpler, safer and more powerful.
Simpler because they don't rely on dedicated syntax- they're just regular objects no different to any other object.
Safer because unlike exceptions, they force callers to handle all potential outcomes, but no more. (no risk of ignoring errors and no risk of catching a higher level of error than desired, ubiquitous bugs in exception based error handling)
Powerful because they support map, flatmap, applicative etc, making it easy to eg chain multiple computations together in desired ways, which is unwieldy and bug prone when using exceptions.
> What is wrong about dedicated syntax
It adds complexity to the language! It could be that, when learning Java, Kotlin and any other language, we learn that methods return what they say they do... and that's that. No weird dedicated syntax and magic, special treatment for returning anything other than the happy path, and the HUGE complexity that comes with it, eg the dedicated syntax itself and how it behaves, differences between checked and unchecked exceptions, hierarchies of exceptions etc etc.
> Exceptions are easier
But that's the point, they're not.
Exceptions based error handling is unnecessary, hugely complex, doesn't compose at all, obfuscates or straight up hides what can go wrong with any given call, so leads to countless trivially preventable bugs... I could go on. And after decades of use, there's still no consensus about what exceptions should be or how they should be used. Exceptions are a failed experiment and I have no doubt that in ten years, Java, Kotlin and many other languages will acknowledge as much and move away from it the same way Joda Time outcompeted and replaced the horrible Java date and time library.
Exceptions have a lot of additional functionality in larger ecosystems such as:
- Backtraces ie. showing the exact path of the error from its source to whereever it was handled, in a zero-cost way. This is by far the most important aspect of exceptions, as it enables automatically analysing and aggregating them in large systems, to eg. attribute blame from changes in error metrics to individual commits.
- Nested exceptions ie. converting from one error system to another without losing information. Extensible with arbitrary metadata.
- An open and extensible error type hierarchy. Again, necessary in large scale systems to differentiate between eg. the cause (caller fault, callee fault aka HTTP 400/500 divide), retryable or permanently fatal, loggable etc. exceptions while also maintaining API/ABI backward/forward compatibility.
(for some of these, eg. Rust has crates for a Result-y equivalent, but a community consensus does not exist, yet...)
General-purpose exceptions simply are complicated, and any system trying to "re-invent" them will eventually run into the same problems. Over-simplifying error handling just results in less maintainable, debuggable and reliable systems.
As the comment clearly indicates, it is about anemic/"naive" functional error handling not being the counterpoint to general-purpose exceptions, not functional error handling vs. exceptions in general.
I do mostly prefer error handling being explicitly marked at every call site (ie. the functional style), but note that this is not always meaningfully possible in very large systems (at least beyond the notion of "I do not know exactly what errors are possible here, just propagate whatever happens" which is equivalent to regular exception handling)
And, as I already mentioned in the original, Rust does have functional solutions to some of these problems, and as other comments indicate, eg. Scala has them as well (probably even theoretically better since it can be a strict superset of the existing zero-cost exception model in the JVM).
Either[Throwable, Option[Foobar]]
The type Try[T] is essentially Either[Throwable, T]Either[Throwable, T], Try, as well as IO from Cats Effect give you the stack traces that you expect from conventional Java style, with the superior option of programming in the monadic / "railway" style. Try also interfaces nicely with Java libraries: val result: Try[Foobar] = Try(javaFunction).
I'd add that they're easier because I don't know of any compiler that makes you handle every type of exception. I know a grip of compilers (and to be fair compilers with options and language extensions set) where you cannot not handle the "that didn't work, what now?" cases: the Left of the Either Left Right, Nothing of Maybe Value, Error of Result Error Value, None of Option etc etc etc.
Languages that force you to handle everything that can happen when you do a thing are an accessibility tool for me and my fellow smooth brains. If I can't see it in my buffer I'm not going to remember to check it.
This is one of my beefs with Golang. The `err != nil` stuff gets exhausting when it's every other line. I get it if you're writing lower-level systems like you might in Rust or C, but Golang is often used for high-level stuff. Java, JS, etc did the right thing for their target use case. JS actually went more towards exceptions when they added async syntax, and if anything I think Golang is going to be the one to cave eventually (like they did with generics).
And my day job used to use Erlang/OTP. It was a neat language with lots of great design decisions, but overall tedious for the type of feature work we were doing. It makes a lot more sense for things like ejabberd.
I get this all the time from people who are used to exceptions, and it's based on a lack of understanding. If you get an Either<Error, User> from the db, you don't need to handle the errors in every method call in the service layer. You can just call map and that function will be applied in the happy case, no need to handle the Error case. Then, at the resource layer, you can simply
userService
.getUser(userId)
.fold({ error -> HttpResponse.400(error) },
{ user -> HttpResponse.200(user) })
As for Go for HTTP REST APIs, agreed that it simply isn't a good tool for that. In fact, I don't think Go is a good tool for anything - for pretty much every use case, there are objectively superior languages, lol. But that's a whole nother can of worms.I've been on both sides of this, preferring explicit handling when I was writing systems code and preferring exceptions when I was writing web backends, so I understand how to use each. Exceptions are popular in high-level languages because that's actually what people want in those use cases.
I also dislike Golang for plenty of other reasons. "Master of none" language like you implied, and late to the party with nothing special to bring other than pointless syntax differences.
No? You just have to indicate it may exist, which is desirable. When you get user by user id from the db, there may be no such user, or there could be a db connection issue. The function should indicate that and compiler should check that you're handling it, not pretend nothing can ever go wrong. (which is effectively the case when you're hiding the unhappy path with exceptions) And there is no such thing as "returning early". In functional programming there is usually a single return statement with a chain IO or Eithers or whatever chained together and they decide themselves where they short circuit, not one return statement for if there's an error and another for a happy path.
In my example, Either<Error, User> might be what's returned natively by a functional db library that doesn't throw exceptions
but in languages without exceptions, like Haskell, it'd look like this
queryItemByID :: IConnection conn => Int -> conn -> IO [[SqlValue]] queryItemByID itemID conn = do stmt <- prepare conn query execute stmt [toSql itemID] rows <- fetchAllRows stmt return rows where query = unlines $ [ "SELECT fieldName, value FROM itemDataValues, itemData, fields WHERE", "itemDataValues.valueID = itemData.valueID", "and itemData.itemID = ?", "and fields.fieldID = itemData.fieldID" ]
queryItemByID :: IConnection conn => Int -> conn -> IO [[SqlValue]]
> queryItemByID 5569 conn >>= mapM_ print [SqlByteString "title",SqlByteString "From Stochastic Calculus to Mathematical Finance-Kabanov.pdf"]
No exceptions anywhere, and you don't have to do anything with any exceptions. You could just send this IO back up to the http layer of your app and turn it into a 400 or whatever on any error if you don't want to disambiguate between them or handle them.
You don't pretend nothing can go wrong. Somewhere up the call stack, you're handling the exception. If nowhere else, whatever webserver lib will send 500 if you didn't catch something.
To give a concrete example with my daily work, we use C++ without exceptions for high-level code for some reason. Because practically every func we write returns an error, the result looks like this:
RETURN_IF_ERROR(DoThis());
ASSIGN_OR_RETURN(const foo, DoThat());
RETURN_IF_ERROR(DoThis());
RETURN_IF_ERROR(DoThis());
in every func down the entire call stack. And it was worse before we finally overrode the people opposed to macros: absl::Status status = DoThis();
if (!status.ok()) {
return status;
}
absl::StatusOr<Val> val = DoThat();
if (!val.ok()) {
return val.status();
}
...
What's the value in putting this macro on every single line, to remind me that everything can fail? I know it can fail. This is like Bart Simpson with the chalkboard. Functional programming languages could have exceptions too if they wanted. It's just syntactical sugar around errors.As for your code example, again, that is not analogous to the functional style. The functional style is you have an Either object no different to any other object. You do not if on the object, you do not early return the object in the sad case etc etc. Just throw all notion of all that out the window. You just pass it around without doing anything, or, if you only wanna do something in the happy case, you send in what to do using map, and then keep passing it around. There is zero requirement to handle the error like you seem to imply. Only at the outermost layer do you handle the error, and only if you wish to do so. (if you don't, just fold it into a 400 or 500 or whatever)
Agreed that within the function call site I'm pretending nothing can go wrong, but not within the entire program. I don't see what's unsafe about that. We're just now implying that anything can return an error instead of restating that fact every time, and instead you have to be explicit about catching it.
About the functional programming... you're saying there's no if-else or early return in functional programming, but Erlang, a functional programming language, has if-else and exceptions ("throwables") that look like Java: https://learnyousomeerlang.com/errors-and-exceptions. Under the hood it's one logical chain like you said, but we're using a lot of syntactic sugar on top.
> if you only wanna do something in the happy case, you send in what to do using map, and then keep passing it around
That's the kind of if-else early-return I mean, and it's probably what you want to do if you aren't using exceptions. My C++ code also (explicitly) passes the error around, albeit not in the functional way. In like 99% of the cases you are just passing the error up.
If IO might fail as a business concern, then we use File.read and pattern match on the return type where we can explicitly handle the error case.
Otherwise, if we know a file will always be there and IO is failing for reasons out of our control, that is truly exceptional so we can use File.read! which will throw an exception on failure. In Elixir we generally don't handle this, we just crash and a supervisor brings the process back up.
You can also add an else in the with so it looks like
with {:ok, fh} <- File.open(filename),
{:ok, contents} <- IO.read(fh)
do
contents
else
{:error, reason} -> File.close(fh)
{:error, reason} # this will return after the file has been closed
end
or maybe you would prefer to open the file, and pass that into the with and either way close the file. I just used IO as an example because they return nice {:ok, x} or {:error, reason} tuples, but this works with any pattern.Your example looks just like exceptions to me, just with different keywords
try:
return with_example(fname)
except RecoverableError:
recover(fname)
except Exception as e:
raise ebut if you returned a list you can pattern match on an empty list, a single element list or a list that's longer. which you wouldn't do with exceptions in another language. that's the nice part about `with` that you can stop once you stop matching the pattern and return whatever you currently have, which in the list example may be a perfectly valid thing to return.
with [value] <- Module.some_list_function(arg), # can return an empty list too
[head | tail] = list <- Module.another_func(value), # can return a single element list
longer_list <- Module.takes_multi_element_list(list)
do
longer_list
end
There are loads of other examples for uses of this, or you can just write in an additional clause for your function that handles the other case and pipe it down the line.It's about what makes sense for your domain.
I may not be explaining this well so apologies for any confusion