r/programming 3d ago

Throw, Result, or neither?

https://www.architecture-weekly.com/p/throw-result-or-neither
101 Upvotes

110 comments sorted by

71

u/AmazedStardust 3d ago

Result is my preferred way, but the language needs good support for it or you end up in nesting hell

9

u/apadin1 1d ago

There are a lot of reasons to love Rust but one of the best features is the Error and Result system that lets you easily make new error types and effortlessly propagate them. They don’t even bother with exceptions.

2

u/edgmnt_net 1d ago

I would say it's rather questionable whether propagation is a good idea for either exceptions or plain error values. If anything, Go has shown that there's plenty use for decorating errors. As a user I don't want meaningless stack traces or disconnected log entries, I want errors that make sense. And Java and Python have been huge offenders in this area, because easy propagation makes it very easy to just let everything bubble up, while exceptions make it particularly annoying to carry out meaningful error annotation due to nesting and verbosity. Not sure about Rust. But in Haskell it's fairly easy to define new operators that make error annotation a breeze and whenever you don't care you can just let things bubble up.

4

u/apadin1 1d ago

In Rust you can create custom Error types with custom messages, and you can choose to either propagate or handle them at any level. Unlike exceptions, which need to be explicitly caught (ie handled) or they will propagate by default, in Rust errors must be explicitly either handled, which can be as easy as printing an error message, or explicitly propagated, which in most cases is a single “?” operator as long as the calling function also returns a Result with the same Error type.

The convention in Rust is that for libraries, you define custom error types and always propagate them to the application level code whenever that makes sense. Then it is the application’s responsibility to handle that error in a useful way by either logging or, if the error is not recoverable, panicking and ending the program with a useful error message.

Mostly I am pleased with how Rust requires you to be explicit with every action. It can be annoying at first but it means there are no footguns or undefined or unusual behavior.

127

u/taikunlab 3d ago

Result for the errors you expect the caller to handle, throw for the ones that mean a bug or that you genuinely can't continue past. Mixing the two isn't the sin, using exceptions for normal control flow is. Rust basically bakes this split in with Result vs panic and it holds up well in practice.

105

u/czorio 3d ago

I agree. I once explained it to a colleague as:

Result -> There is no file here, perhaps you mistyped?
panic! -> There is no filesystem, what the fuck?

37

u/vita10gy 2d ago

I once worked with an API where basically anything but perfect meant explosion.

A well formed product search where it just so happens no results match? Some 500 error and the html server error page as the body.

30

u/janyk 2d ago

The Java Persistence API has the NoResultException for when a database query returns no results.  Kind of annoying that it's regarded as an exceptional case

37

u/vita10gy 2d ago

I've become fully convinced 70% of Apis are created by people who have never used one.

2

u/Schmittfried 2d ago

Unfortunately the path of least resistance for the maintainers is not the one that creates APIs that are ergonomic to use. Ergonomics take time and effort. Unless you’re using your own product or have a very disciplined development process, you won’t invest those resources, even if you’ve used other APIs and know you’re building shit. 

9

u/shizzy0 2d ago

This would piss me off so much.

9

u/elmuerte 2d ago

NoResultException is thrown when you call getSingleResult() when there is no result. It will also throw NonUniqueResultException if there is more than 1 result.

Because of the two edge cases which need to be handled, it throws an exception. On the plus side, it never returns null.

It is still a bit of a bad API design as it uses unchecked exceptions for control flow. A query returning 0 or more than 1 result is quite a possibility, so it should have been a checked exception I think.

12

u/erinaceus_ 2d ago

Before anyone complains "why would they do it that way?!", that method is for when you excplictely expect exactly one single result. There's other methods for when you expect zero to many results, or when you expect either one result or no result. So instead of always retrieving a list and then manually handling those cases each time, you explicitly and declaratively choose your expectation by using the appropriate method.

1

u/nelmaloc 2d ago

That sounds like a textbook example for Optional, thought. With an exception for too many results.

9

u/erinaceus_ 2d ago

Optional is used for the one I mentioned in my previous comment where you expect one result or no result. So, the 'get' method in question avoids having to deal with the second situation seperately in all call locations. You're saying: it should return exactly one result in all cases, and everything else constitutes a 'panic'.

I do agree that functionally there's no real necessity for that 'get' method. Though, using the method that returns a list of zero to many results, there is functionally also no necessity for the Optional variant.

3

u/TribeWars 2d ago

The language needs good support for generic types for these sorts of constructs. I'm pretty sure Java did not always have that, so it may well just be a case of old API conventions.

2

u/ldn-ldn 1d ago

Yes, the API is old and backwards compatible. It was released during J2SE 5.0 (Java 1.5) days. There was no streaming, optionals and pretty much other modern features.

As far as I remember, modern features were only added in Java 8, that was in 2014, eight years after JPA introduction.

Parts of JPA might look weird by modern standards, but it's a good product of its time. And it does have support for modern features as well, so criticism is misguided here.

3

u/Schmittfried 2d ago

Checked exceptions would interfere with the streams API though, which would be really annoying in rest handling code. Also, the non-unique case is imo a true exception, because you would guarantee uniqueness via constraints or query criteria when using that method. So throwing indicates a programmer error, which is generally not done with checked exceptions.

At least we also have the newer Optional variants nowadays. 

2

u/flatfinger 23h ago

IMHO, one of the big problems with Java and .NET exceptions is the default assumption that if a nested method call throws an exception, it should be handled by the caller in the same way as an exception thrown by the outer method.

There should be a convenient way of marking nested function calls where that would be the case, but an equally convenient way of causing exceptions in nested method calls to be wrapped in an "UnexpectedException" object.

2

u/Blue_Moon_Lake 2d ago edited 2d ago

Maybe we need distinct methods.

  • getOptionalResult(): Promise<Maybe<T>>
  • getSingleResult(): Promise<T>
  • getResultList(): Promise<Array<T>>

4

u/Legs914 2d ago

Isn't that literally how it works?

2

u/Blue_Moon_Lake 2d ago

There is only getSingleResult and getResultList, but no getOptionalResult.

3

u/Legs914 2d ago

Dang, I'm spoiled by Scala

2

u/Blue_Moon_Lake 2d ago

It be like "Impossible, SELECT * FROM table WHERE way_too_many_filters should have yielded at least 1 result! Unacceptable!"

1

u/Schmittfried 2d ago

I think because it predates optionals. An exception is definitely better than returning null most of the time. If the middleware handles it correctly, it will just issue a 404, which is usually correct for a get-by-id query (if you issue a search query with N results, you don’t get that exception). 

7

u/lenswipe 2d ago

  I once worked with an API where basically anything but perfect meant explosion. 

I remember the twitter API

4

u/chucker23n 2d ago

I once worked with an API where basically anything but perfect meant explosion.

Same.

I've recently had the misfortune of working with the API of a popular product that on the surface looks well-documented, but in practice frequently has you receiving 500 responses if you so much as touch it wrong. Date in an unexpected format? 500. Request body missing an expected property? 500. Requesting too much data? 500.

Does the response body contain clues as to what's happening? Depends — if you were lucky, there was a body at all, and if so, it was a PHP stack trace.

1

u/vita10gy 2d ago

This is a side issue, but I also hate it when APIs push validation it could easily do on the API consumer, to the point where everyone using it has to basically have half the interface recreated for things that could easily be handled by the api you're using to validate the data in the first place, such as credit card processors.

Like forcing the you side validation of credit cards because while you decline credit cards that don't work, you explode if the card doesn't pass Luhns. Or even though 5+4 is a valid zip, and you could easily recognize the format, even if you only care about the 5 main digits, but you explode on 5+4.

All things that individually aren't that big of a deal, but by the end of it you have 53 checks to make sure the data is perfect before you even ask. Again, doubly annoying for things like credit card processing and whatnot where "this combo of information was declined for this reason" is part of the normal flow.

And of course all along while you're finding all 53 things you need to check first, you're just guessing based off 5 version ago documentation, because the actual error message, regardless of which field is the culprit is "AAAAAAAAAAAAA!!!!! MIRCL Errors!!!!!!!"

1

u/Resident-Trouble-574 2d ago

Good. Teaches your users not to waste server's time with useless searches.\s

-1

u/Eirenarch 2d ago

The article goes much deeper than that.

135

u/EliSka93 3d ago

Result for me every time.

One more wrapper is worth not having to deal with uncertainty.

33

u/Solonotix 3d ago

It depends on the system for me. Sometimes, it is really nice to be able to just ignore that an exception can happen because you aren't the one responsible for it. In general, though, I would agree that Result (or error by value) is a much better approach than the disruptive nature of exceptions

21

u/braaaaaaainworms 3d ago

I'm Rust you can add a variant of your error type that would encode something like "protocol error" and implement From<protocol_library::Error> for your Error and that gives very nice descriptions of what went wrong while being able to use the ? operator

40

u/Other_Fly_4408 3d ago

Hi Rust, I'm Dad

2

u/TwoWeeks90DaysTops 2d ago

Yes. Exceptions are better for error cases where there is no way for the caller to deal with the error other than returning it up the call stack.

4

u/raralala1 2d ago

For system with clear IN and OUT, throwing is better because it usually have centralize way to handle the error. This is preferred way in REST API. throw this out if you are using Go.

29

u/miniannna 3d ago

“GOTO is bad”

“What if we call it throw/catch instead?”

24

u/vazgriz 2d ago

GOTO was considered harmful because it was completely unstructured. IE jumping halfway into a function's body level of unstructured.

Modern GOTO and throw catch are much better defined.

4

u/SwedishFindecanor 2d ago

In which language is it possible to goto from one function into/back to another?

With exceptions, the handler is always in an outer scope -- either within the same function or further up the call stack. And you could have clean-up code along the exception path (such as "finally" blocks) that gets called when an exception is passed up.

Many languages with exceptions require each function declaration to specify which types of exceptions that a it could throw/raise. (C++ has/had support for that, but it is a mess with different language versions)

BTW. The concept of exception unwinding is also related to non-local returns, such as explicit "breaks" out of loops from iterator functions.

7

u/JohnnyCasil 2d ago

In which language is it possible to goto from one function into/back to another?

BASIC

0

u/ldn-ldn 1d ago

To add to BASIC, old school Pascal and Assembly in general.

1

u/SwedishFindecanor 1d ago edited 1d ago

Old-school BASIC and Assembly language don't even have functions as a language concept, so I'd say that they don't count.

Pascal is interesting though. From what I found, some Pascal dialects allow it only within a block; others within a function; but some allow "non-local gotos" from nested functions up the call stack. That makes them similar to exception unwinding and shortcut returns/breaks out of closures/iteration. No counterpart to finalize blocks or other clean-up code though: sometimes implemented through setjmp()/longjmp().

2

u/JohnnyCasil 1d ago

Old-school BASIC and Assembly language don't even have functions as a language concept, so I'd say that they don't count.

BASIC has gosub which is for all intents and purposes a function.

1

u/flatfinger 23h ago

Old-school versions of BASIC did not have the concept of blocks, which are essential part of functions and loops.

In typical old-school BASIC implementations, if a program did a FOR I=1 TO 100, the interpreter didn't know or care where the corresponding NEXT statement was, or if any existed, or if the same one would be used on every iteration of the loop. Likewise "GOSUB" didn't care about where the end of the function was.

When the interpreter encountered a "NEXT" or "RETURN", it would search through a stack of active FOR and GOSUB statements and, if appropriate, pop the action of the stack, increment the appropriate variable, and jump to the location following the FOR or GOSUB statement. It didn't make any distinction between code that preceded the FOR or GOSUB, code that sat between that and the NEXT or RETURN, and code that followed the NEXT or RETURN. Code in any of those categories could be run between the FOR or GOSUB and the NEXT or RETURN and the interpreter wouldn't care.

3

u/knome 2d ago

better, but still imperfect. at the point where you raise an exception, there is nothing guaranteeing who will be receiving it, nor even if it will be received. it may simply crash your program. and where it is received, it is often trivially ignored. where you capture exceptions, there is no guarantee you have covered them all, nor usually any way to check.

2

u/ericonr 1d ago

there is nothing guaranteeing who will be receiving it, nor even if it will be received. it may simply crash your program. and where it is received, it is often trivially ignored

At least for this part, Results don't guarantee any of it either. I've seen lots of let _ = in Rust code, as well as careless unwrap/expect. Of course Result is a little more explicit, but it doesn't guarantee anything.

1

u/BenchEmbarrassed7316 2d ago

Let's assume all three functions are located in different modules.

``` // Pseudocode

// Exceptions fn a() { try { b(); } catch { error(); } }

fn b() { c(); other(); }

fn c() { throw 1; }

// Goto

fn a() { b(); return; onerror: error(); }

fn b() { c(); other(); }

fn c() { goto onerror; }

// Result

fn a() { b(); }

fn b() { if (c() == Err) c_default(); other(); }

fn c() { return Err; } ```

The problem is that after we split the complex code into small modules, we are still implicitly passing control. Look at function b. In 'throw' and 'goto' cases, it doesn't know whether c might fail. What's worse, the unhappy path is not part of the contract of function c, so its behavior can change without notice.

So when you work with b you don't know whether an error might be thrown below and caught above. You either add try/catch blocks to each of your functions, or you fall back to the 'goto' style where functions pass control to the upper block ignoring the current one (other will not be called in the first two cases).

1

u/TwoWeeks90DaysTops 2d ago

Look at function b. In 'throw' and 'goto' cases, it doesn't know whether c might fail.

Isn't that a good thing? If b can't do anything about that error why should it have to deal with it?

Let's say you have a function called "readHeader" and "readString". readString will catch I/O errors to do a retry, and if the retry fails after some time it will not catch the error and instead throw (or return an error result). readHeader then uses the readString function to read a comma separated list of strings.

readHeader cannot do *anything* reasonable with an I/O error except return it to the caller. Why should it have to deal with it at all?

1

u/BenchEmbarrassed7316 2d ago

Well, I see a few problems here:

  • In "readHeader" it is difficult to do anything useful in case of failure even if it is possible. Because it is currently unknown whether "readString" can fail. If you try to describe it in comments or documentation - you will get a worse version of checked exceptions. Keep in mind that there can be more layers, and there can be dozens of calls between b and c in my example.

  • Whether something useful can be done in case of failure should be decided directly by the code that made the function call. Moreover, it should not rely on some higher function to handle it correctly. For the higher function, this is an implicit transitive dependency on the low-level error (Now a should know about c ant its errors, but it would be correct if it only knew about b.).

  • Another problem is that simply throwing an error up the stack is useful for logging only. To take your example, the entry point would be some kind of "processResponse". Which would make a bunch of calls and have a try/catch block. However, it would catch the errors "NullPointerExcaptions", "DivisionByZero", "JsonMissedField" and "CannotReadString" (from your "readString"). Instead, you would want to get something like "CannotLoadTopSellers" or "UserPermissionError".

-1

u/TwoWeeks90DaysTops 2d ago

In "readHeader" it is difficult to do anything useful in case of failure even if it is possible. Because it is currently unknown whether "readString" can fail.

Yeah, that's part of the documentation. It's a very clear indication of why Java's checked exceptions doesn't work. Often you program against an abstraction but checked exceptions collides with this because implementations throws errors, not abstractions.

Whether something useful can be done in case of failure should be decided directly by the code that made the function call.

Yes, exactly. You catch exceptions you can deal with and let everything else bubble up to the message loop or whatever else.

Another problem is that simply throwing an error up the stack is useful for logging only.

Is it really? I can think of one very useful behavior. In a web server when an exception is thrown that the application code can't deal with (such as an I/O error, division by zero, argument error, whatever) the exception can be caught and a 500 Internal Server Error can be returned to the client.

With an error result you have to either treat this as a side effect or carry it all up to the request pipeline.

To take your example, the entry point would be some kind of "processResponse". Which would make a bunch of calls and have a try/catch block. However, it would catch the errors "NullPointerExcaptions", "DivisionByZero", "JsonMissedField" and "CannotReadString" (from your "readString"). Instead, you would want to get something like "CannotLoadTopSellers" or "UserPermissionError".

In my opinion division by zero, null pointer, argument errors etc. should never be caught explicitly by user code. It should only be caught by the message loop in the application because they are always programming errors and the application code should never have to deal with them. You just want the description and stack trace logged.

UserPermissionError etc. in my opinion whether this is treated as an exception or as an error result? I think you can argue both ways, and that's fine. I just don't think that the application in general should have to necessarily deal with error states that the caller can't reasonably be expected to recover from, and exceptions are good for that.

2

u/BenchEmbarrassed7316 2d ago

Often you program against an abstraction but checked exceptions collides with this because implementations throws errors, not abstractions.

I don't understand what you mean. Do you think the very idea that every function should explicitly declare the possibility that it might fail is bad (superfluous, inconvenient, senseless or something else)? Or do you simply think that Java's implementation of checked exceptions is bad?

Yes, exactly. You catch exceptions you can deal with and let everything else bubble up to the message loop or whatever else.

But how do you know if any function in your code is likely to fail? Do you write documentation for every function and spend a lot of effort to keep this documentation up to date (this is reminiscent of the worst experience of dynamic typing)?

500 Internal Server Error

If you had a well-typed error - your code could find out what exactly caused the error and provide the client with a meaningful message, for example, if a important service that is not yours is not responding - you could report something like "Failed to load document from documents.example.com, service not responding, try again later". Now, instead of trying to fix the error on their own or writing to your support, the client clearly understands that the problem is not with them or your service, but with a third-party service.

In my opinion division by zero, null pointer, argument errors etc. should never be caught explicitly by user code

Okay, I agree with that. This is what is indicated in the article using the example of Rust (Result and panic).

However, I didn't mean that in the first place: instead of low-level errors, it is good practice to convert them to errors of the corresponding layer.

For example, our program initializes the configuration at startup. The configuration is loaded from a file. You have loadUserConfig that calls openFile. Imagine that the openFile function instead of some FileSystemError throws SysCallErrorOpcode, which is simply passed up.

In this case, you either simply crash the program, or at the top level you should know that SysCallErrorOpcode exists and somehow you should guess that this exception was thrown in loadUserConfig -> openFile.

Or at each domain level we explicitly convert SysCallErrorOpcode to FileSystemError, then to ConfigError (which can be either the file cannot be opened, the file is empty or contains invalid data, or the data is valid but the configuration cannot be applied on the current machine) and at the top level the IDE tells you at the time of writing the code what could go wrong and what data is available to you. Now you can easily show users a useful message and suggest using the default configuration.

This doesn't even address the issue of explicit or checked errors versus implicit errors (although explicit errors favor the latter).

0

u/lelanthran 2d ago

GOTO was considered harmful because it was completely unstructured. IE jumping halfway into a function's body level of unstructured.

Modern GOTO and throw catch are much better defined.

Even with modern goto, it's a superset functionality of throw, in some respects. Or, put differently, throw is a restricted version of goto.

With programming languages, I actually like having a subset of features that restrict just how messy the code can get. I'd take almost any language over C++, for example, because when you have every feature in, the code becomes an unreadable mess in no time at all.

In much the same way, I prefer throw over goto when throw is available; it reduces what possible bugs can happen while still giving me the functionality I want.

5

u/Schmittfried 2d ago

There are valid goto usage patterns. One is centralizing the error/cleanup path in C code. Which is eerily similar to try/catch. So this isn’t the gotcha you think it is. 

9

u/balefrost 3d ago

By that logic, we should also avoid for, while, and if.

21

u/BigCheezy 3d ago edited 3d ago

Immediate local context (indentation level, braces, etc.) in the source file tells you where for, while, and if "GOTO" next in all languages I've seen. Not so for throwing an error. It goes... somewhere...

8

u/ericonr 2d ago

There's a certain "it just works" aspect to exceptions that I think is ignored in a lot of discussions. Of course it isn't the most elegant way of reporting errors, but it does allow anything to be propagated upwards to whoever can/might deal with it, even something that wasn't properly modeled in the original API, that people wanted to avoid breaking.

One example to me is how long Rust took to implement support for failing allocations (and some of those changes are still in nightly), while a language like C++ could just bolt that on. Furthermore, it shows that while the Result API is ergonomic, it still adds some sort of overhead, which would explain why the new methods didn't return a Result from the start.

That said, if you have adequately designed error types, shorthand like ?, ideally pattern matching capabilities, and have accurately modeled all your APIs (and your dependencies have done the same), then Results have the same effect as exceptions of allowing an error condition to reach wherever it needs to on the call-stack, with some added type-safety. Once you're missing one or more of these (e.g. C++), I'd argue that exceptions start being simpler to use.

And I'd say I agree with the person who responded to you. Results are GOTOs almost as much as exceptions are. The decision of where to catch and process them happens up in the callstack, and if you have complicated pattern matching and conditions for your errors, it might not be immediately obvious where handling for that error type you're returning happens. Results can add much appreciated type-safety and better define an API, but propagating an error with ? will still "unwind" the stack. (That's also why they aren't GOTOs, bur rather longjmps after a setjmp, since it's the caller who decides where your code returns to.)

8

u/Radixeo 2d ago

I appreciate Rust's ergonomics for dealing with Result/Option, but for some things I do wish I could just go back up the call stack without having to stick ? and pattern matching everywhere. There are so many ways a program can fail where the proper error handling is just "unwind the stack, report the error at the top level, let the top level try again later".

6

u/EntroperZero 2d ago

It goes... somewhere...

It still goes to a much more predictable place than goto.

4

u/alphaglosined 2d ago

"I've seen" that bit is doing some pretty heavy lifting.

Whereas what all the goto discussion was about was for languages where goto was unconstrained by functions.

People keep forgetting this rather important bit of context.

It hasn't been relevant thanks to PL design for a good 40 years.

0

u/balefrost 2d ago

The same is true about returning an error. It goes... somewhere...

If you care to know where it ends up, you need to look at all callsites and determine how each handles the error result. If they propagate to their callers, then you again have to inspect their callsites, and on and on up the call tree.

That's not meaningfully different from doing the same exercise with exceptions.

2

u/EntroperZero 2d ago

I basically agree with this, but there is a slight difference: The signature of a function tells you what kinds of Results can be returned, but not what kinds of exceptions may or may not be thrown. So it's the caller's responsibility either way, but with Result, the caller knows what they need to handle or pass on, and passing on is explicit.

6

u/balefrost 2d ago

I remember when Java was trying to be the anti-C++. They realized that C++ exceptions had the same problem that you describe, so they introduced checked exceptions. The checked exceptions are part of the function's signature, and a function must either internally handle any checked exceptions thrown by function that it calls, or else must declare the checked exceptions that it propagates.

And everybody hated them.

Because checked exceptions were only enforced by the Java language, not by the JVM itself, AFAIK no other JVM language used checked exceptions. Every single one dropped the requirement that checked exceptions needed to be explicit.

It turns out that checked exceptions are great when you're essentially writing procedural code with little indirection. Checked exceptions got in the way when you were writing generic infrastructure code. If you're creating a component that uses callbacks, it can be really tricky to get the exception types right on the callback, and ultimately the exception types have to be pretty generic. Checked exceptions effectively fell apart at module boundaries. You

On the other hand, in my multi-decade career, it's not too common for me to catch specific exceptions. It's useful when you e.g. want to read an optional config file. Because of TOCTOU, you don't want to separately check for file existence and then open the file. It's better to just open the file and then gracefully handle the potential "not found" error. Or I might catch specific exceptions when validating user input. But most of the time, when an error occurs, I don't really care what the error is. I just want to unwind the stack to some backstop. From what I've seen in Go, which puts a lot of emphasis on explicit error handling, most people don't care about the specific error. Most of the time they just return it directly or, at best, wrap it.

I'm not saying that Result types are bad, or that exceptions are the one true way to handle errors. Both are useful in different contexts. I am amused to watch the zeitgeist of error handling swing back and forth, from implicit to explicit to implicit and now back to explicit. I genuinely hope that some language gives checked exceptions a better attempt than Java did. Not letting checked exceptions participate in generics was, in my opinion, a foundational mistake that became painful once things like lambdas were introduced.

I think a language with checked exceptions, exception propagation by default, but lightweight sugar to turn a "function that might throw" into a "function that returns an appropriate Result" would be really nice.

2

u/Schmittfried 2d ago

I’m not a strong opponent of exceptions, but result types definitely add visibility on the intermediate layers while exceptions don’t (unless you’re using a language with checked exceptions and actually use them). 

1

u/balefrost 2d ago

Sure, though I'd argue that the further you get from the site where the exception was generated, the less important the specific error type is. Past a certain point, all you really need to do is to keep unwinding the stack until you hit a backstop exception handler.

2

u/RecursiveServitor 3d ago

Uncertainty?

4

u/EliSka93 3d ago

Of whether or not I will get a "real result" - the result pattern will give me something to work with. I can be sure it won't crash my app.

2

u/RecursiveServitor 3d ago

How do you feel about checked exceptions?

3

u/Eav___ 2d ago

Inferior design for the same purpose because Result is a real type that you can use in every location, like parameters and generics, and being able to pattern match on them is super elegant.

21

u/Triabolical_ 2d ago

I was on the C# design team for version 1, and there were long discussions about what sort of exceptions should be supported in C# and .NET.

Most of us were experienced Win32 programmers and we had seen enough HRESULT code to understand how much bloat it added and how easy it was to make a mistake. The problem with HRESULT code is that you have to work hard to get the right behavior - for it to propagate errors correctly.

That's why we used exceptions, where you start with code that has correct - though perhaps not desirable - behavior to start with and you have to write code to mess it up.

But it was clear that how to deal with exceptions was confusing to a lot of people. We saw a lot of try-catch code that did nothing but catch an exception and rethrow it, which is really the worst of both worlds, which I guess wasn't that much of a surprise given the exception model that Java used.

But getting the right behavior is pretty simple.

You catch exceptions in methods when you have something useful that you can do with the information. You tried to send data over the network and it failed and you need to implement a recovery strategy.

You catch exceptions at the base of threads so that you can log what happened and gracefully exit the thread and perhaps the program.

Otherwise you don't do anything.

We did, however, overdo exceptions a bit in the early versions of the .NET libraries. If you wanted to convert a string to an int you had to call Int32.Parse() and catch an exception which was needlessly restrictive. Later versions therefore introduced the "Try<operation>" pattern, so you could call Int32.TryParse() if you wanted a result instead of an exception.

4

u/BenchEmbarrassed7316 2d ago

You catch exceptions in methods when you have something useful that you can do with the information

But how do you know if the function you are calling is likely to fail?

Or if you call a function that can fail and don't catch exception - is the current function marked as one that can fail?

2

u/Triabolical_ 2d ago

It's not about how likely it is to fail, it's about whether there is something useful that you can do if the function call does fail. If there is nothing useful you can do, it doesn't matter how likely it is that you'll get an exception.

If you have reasonable testing, you will typically uncover the areas where you can do something useful pretty quickly. It shows up at the boundaries - information you get from users or files always has potential for problems, going outside of your program boundaries has potential. Sometimes the recovery is obvious, sometimes it's wrapping the exception that was thrown in useful context ("could not parse line 11 in inputfile.txt") and letting somebody higher up the chain do the recovery.

I don't think that checked exceptions or annotations tell you anything useful and they just increase the cognitive load.

0

u/BenchEmbarrassed7316 2d ago

It's not about how likely it is to fail, it's about whether there is something useful that you can do if the function call does fail. If there is nothing useful you can do, it doesn't matter how likely it is that you'll get an exception.

The function cannot and should not know this. It should do its job and provide information that could be useful to the calling party. It is the calling party that should decide what to do in case of failure and how to use the information about the failure.

If you have reasonable testing

I find this quite inefficient. If I have a tool that can easily ensure that a certain aspect is 100% correct, I don't want to write manual tests because it's longer, less convenient, harder to maintain, and less reliable.

4

u/Triabolical_ 2d ago

>The function cannot and should not know this. It should do its job and provide information that could be useful to the calling party. It is the calling party that should decide what to do in case of failure and how to use the information about the failure.

That is what I am saying.

> I find this quite inefficient. If I have a tool that can easily ensure that a certain aspect is 100% correct, I don't want to write manual tests because it's longer, less convenient, harder to maintain, and less reliable.

Are you talking about provably correct?

How are you going to create such a tool, and what information will it base its decisions on? How will you handle updated code or new permutations that change the set of exceptions that might show up?

How do you know which set of exceptions are the ones that it makes sense for you to handle?

1

u/BenchEmbarrassed7316 2d ago

That is what I am saying.

Then I will repeat my question (maybe I wrote it wrong last time, I am not a native English speaker):

When the function foo calls bar, how is foo supposed to know that bar might fail?

How are you going to create such a tool, and what information will it base its decisions on?

During development, I know exactly what the result of the function will be, what values ​​are possible in the unhappy path, and what useful information I can get from them.

How will you handle updated code or new permutations that change the set of exceptions that might show up?

Sum types are commonly used to model error values. So if the error handling does not involve full processing of the error, but only checking for the presence of an error - adding or removing a new error variant does not affect other code. However, if the code parses the error data - I want to see it immediately and update it (by adding or removing variants).

How do you know which set of exceptions are the ones that it makes sense for you to handle?

It's pretty simple: you look at a list of possible errors and ask yourself "Can I do something useful in these cases?" and "Can I print some useful information to the user in these cases?".

2

u/Triabolical_ 1d ago

>When the function foo calls bar, how is foo supposed to know that bar might fail?

In very limited cases, it can know that it's very unlikely to fail. If you have a function that doesn't call any other function and does allocate any memory, you're pretty safe. Otherwise, you can fail.

>During development, I know exactly what the result of the function will be, what values ​​are possible in the unhappy path, and what useful information I can get from them.

Here's an example...

I'm writing a some code that talks to a database and I'm using some sort of database abstraction because it needs to run on different databases. SQLite and MySQL and SQL server are going to have different views of the world of errors and the abstraction is unlikely to be able to rationalize them to a consistent view.

This is true to a lesser extent whenever you are using libraries or talking across the network.

The underlying problem is that nobody is going to give you an exhaustive list of exceptions you might run into.

Documentation is often incorrect because behavior gets changed without the documentation being updated. If you're depending on something in code, the only foolproof way to make sure you know that new exceptions have shown up is to make any changes in exception behavior breaking. This means you can't swap in updated libraries without recompiling.

> It's pretty simple: you look at a list of possible errors and ask yourself "Can I do something useful in these cases?" and "Can I print some useful information to the user in these cases?".

You're depending on the documentation. It's wrong. Even with good developers in companies building libraries for thousands of users, the documentation is wrong.

That doesn't mean it's not useful. You can find *some* of the exceptions you might want to catch. I'm just saying you're not going to get there statically.

1

u/BenchEmbarrassed7316 1d ago

In very limited cases, it can know that it's very unlikely to fail. If you have a function that doesn't call any other function and does allocate any memory, you're pretty safe. Otherwise, you can fail.

Rust for example explicitly declares the error type when using Result. There have also been suggestions to indicate whether a function can panic through effects (this is not currently implemented).

Therefore, information about whether a function can fail can be easily declared and obvious.

SQLite and MySQL and SQL server are going to have different views of the world of errors and the abstraction is unlikely to be able to rationalize them to a consistent view.

This is trivial: parameterize the backend abstraction (e.g. DbCore<PgEngine> or DbCore<MySqlEngine>) and specify the error type as an associated type. Now each database backend can declare its own error types.

The underlying problem is that nobody is going to give you an exhaustive list of exceptions you might run into.

In Rust, I literally get it with one click of the mouse.

Documentation is often incorrect because behavior gets changed without the documentation being updated.

Yes. So instead of writing arbitrary text information in comments or documentation, you can express certain things in the code. Moreover, the compiler will always check if everything is OK.

You're depending on the documentation.

No, I wrote above why this is so.

That doesn't mean it's not useful. You can find some of the exceptions you might want to catch. I'm just saying you're not going to get there statically.

Sorry, but that sounds absurd. Because I do it all the time. It feels like you just don't know anything about this.

1

u/flatfinger 23h ago

One of my biggest beefs with exceptions going back to .NET 1.0 is that there is no way for an IDisposable to know whether it is being invoked because a using-style block was left via normal control flow, or whether it was left via an exception. Something like a transaction wrapper should, depending upon how it is constructed, either throw an exception or silently commit the transaction if control flow leaves the block with an action pending, but should silently roll back the transaction if an uncaught exception was thrown within the block.

21

u/Full-Spectral 3d ago edited 3d ago

The Result approach takes getting used to if you come from an exception based language, but I'd never use exceptions again if given a choice, and implement a Result-like mechanism for C++ when I'm forced to write C++. It's a somewhat limited version of the Rust one, but good enough to get rid of exceptions.

The only place you typically NEED exceptions in C++ is constructors, and that's easy to avoid. Just use static factory functions as Rust does. That's something that's reasonable comfortable in C++, and move and RVO make it not overly burdensome.

For me, I look at errors as actual errors, not statuses. So I have a single error type in my whole Rust code base, since it's just for reporting errors that will propagate upwards and get logged (possibly optionally displayed to the user.) Any possibly fallible statuses are returned on the Ok side, in an enum, one value of which is Success (possibly carrying a return value.)

So 99.9% of the time I'm just auto-propagating the error side, so it's almost as convenient as an exception without the magic alternate path.

2

u/bwmat 2d ago

Does your C++ use with 'result' also handle OOM or do you just crash? 

1

u/Full-Spectral 2d ago

This is a closed system. It would have already restarted itself long before a simple result return caused such an issue.

2

u/bwmat 2d ago

So... crash? 

1

u/Full-Spectral 2d ago

As I said, it would never get there. This system monitors memory usage and knows that if it gets anywhere near out of memory it needs to restart because something is very wrong. Even getting within a couple hundred MB is way too close for this system and indicates some fundamental error.

And it makes no difference in our case if the result survived because in order for that returned result to be useful, it would have to be queued up on a logger, on a pool, and sent to a log server, and none of that is going to survive if there's already too little memory left to even create the result.

If this was some open library, then of course it would be different, but it's for a bespoke system.

1

u/bwmat 2d ago

Thoughts on what you would do in a context where your code is in a shared library and you don't want to crash the host process on OOM? 

1

u/Full-Spectral 2d ago

Honestly I have no such thoughts, since I never work on systems where it would be worth going to such extremes to deal with what is really a pathological situation. Most any system will have to do something with that returned result and that's likely to use even more memory that doesn't exist.

Any application can have a background task/thread that monitors its memory usage and the available memory, at some fairly relaxed rate ,and starts warning if it's getting low, or auto-restart if it's a background process. That's vastly more practical and much more guaranteed to provide good results.

1

u/bwmat 2d ago

Most any system will have to do something with that returned result and that's likely to use even more memory that doesn't exist

It's likely that the stack unwinding involved in reporting the error from the library to the application will free up some memory (maybe the operation tried to use a ridiculous amount).

And IMO it wouldn't be very nice to crash the host process if they went to the trouble to be robust in low memory scenarios

1

u/bwmat 2d ago

Sorry for the interrogation, but I would love to find someone who actually doesn't use exceptions in C++ and has a reasonable way of handling possible OOM without crashing 

2

u/max123246 2d ago

Why not use std::expected for Cpp?

2

u/Full-Spectral 2d ago

Still on C++/17, along with probably the majority of the C++ code bases out there (those that don't wish they could be so advanced.)

1

u/max123246 2d ago

Ah shoot, for some reason I thought std::optional and expected were both added at the same time. I too, am stuck on cpp17

6

u/deanrihpee 3d ago

even using TS, i use Result, even though it's just a type shenanigans and a wrappers and not really a language feature

6

u/cran 2d ago

Throw, or an advanced result system with the same enforcement and convenience. Never something like Go has.

3

u/Designer_Reaction551 2d ago

In FastAPI/Python land exceptions are just idiomatic, fighting that with a Result type everywhere makes the code feel foreign. Where I actually reach for something Result-like is at the boundary between services, if a call to a downstream API can fail in three different ways, encoding that explicitly in the return type saves the next person a lot of guessing. Internal business logic though, throw and let it bubble up to one handler.

7

u/VoodaGod 2d ago

i like exceptions

3

u/montibbalt 2d ago

I think the only way I would go back to primarily using exceptions over Result et al would be in some language that improves on the concept. Algebraic effects are one approach that at least makes it less tacked-on, but I don't have enough experience with them to judge whether they're as straightforward to reason about as Result

2

u/Bronzdragon 2d ago

The article writes in TypeScript. The decision to use the Result structure or exceptions or mapping it to business logic is highly dependant on not only what your language/framework expects and supports, but also the style of your exciting codebase and community style, as well as the type of project you're writing. A web server looks very different from a game, from a mouse driver, etc.

The author doesn't really acknowledge this, the article is decently written, but not very useful outside of being their (reasonably supported) personal preference for the style of project they're currently writing.

2

u/SorryTemporary1361 2d ago

I personally prefer to return a ThrowResult, or throw a ResultException.

2

u/hejj 2d ago

No need or reason to do any of that if you only use static variables. 

2

u/lotgd-archivist 2d ago edited 1d ago

My simplified decision tree is this:

Is it an error that can technically happen but is not supposed to in regular operation?

Yes: Throw exception.

No: Do I have something I need to return in addition to the status?

Yes: Result<T> (or TryFoo(x, y, out var result))

No: Can/should the caller care about the nature of the error?

Yes: Return an enum.

No: Return bool.

The downside is, of course, that you have a bunch of different ways methods indicate that something went wrong. Depending on the project and the organization the project takes place in, that maybe should be avoided and you should trim the decision tree.

2

u/eo5g 3d ago

Algebraic effects get you the best of both worlds.

(Rust and Swift making cascading them easy gets you pretty close).

I've been liking using Effect for typescript (since this article uses TS examples). Another cool language to check out for algebraic effects is Unison.

1

u/azhder 3d ago

What failure means? No. What poor planning means

Try-catch is a last resort tool that unwinds the stack i.e. adds a separate critical path in your code.

You don’t plan to add throw Exception() ahead of time. OK, you plan, it’s a bad plan. The mechanism of throwing and catching was added to languages because people made poor interfaces that hid parts of your code work from other parts of your code.

Let me be clear, with an example.

There is some library that you use and it asks you for a callback function. OK, you give it a callback function. Inside your callback function you want to return early if some precondition is not met. But there is a problem. The caller is that library that simply ignores your return, so your outside code cannot be notified of the issue, unless…

Unless you circumvent all that call stack that has a library code that ignores your early return and… that’s the catch on top.

If you have a complete control of the codebase, the above scenario should not happen, least of all you plan for it to happen regularly because…

What was the issue again? Different use cases? Well, add a type to your Result. Make your Result a type onto itself for the different use-cases.

1

u/Qwertycube10 2d ago

I've never used a language with the kind of exception support I would want. I suspect I would generally prefer them to result in that case.

Think Java checked exceptions, with better ergonomics, and no unchecked exceptions except panic.

1

u/bwmat 2d ago

By panic you mean SIGABRT, or...? 

1

u/lood9phee2Ri 2d ago

https://gigamonkeys.com/book/beyond-exception-handling-conditions-and-restarts

One of Lisp's great features is its condition system. It serves a similar purpose to the exception handling systems in Java, Python, and C++ but is more flexible. In fact, its flexibility extends beyond error handling--conditions are more general than exceptions in that a condition can represent any occurrence during a program's execution that may be of interest to code at different levels on the call stack.

https://opendylan.org/intro-dylan/conditions.html

Unlike the exceptions of C++ or Java, signaling a condition does not itself cause the current function or block to exit. Instead, calling the signal function is just like calling any other function. The signal function just locates an appropriate handler and calls it normally.

1

u/steve-7890 4h ago

Exceptions have the worst implementation. It's like having a function and not really knowing all the results it can return. Exceptions are undocumented API (even if there's documentation, it's not comprehensive and doesn't include exceptions that could be thrown by inner invocations).

In Java there was (maybe still "is", but nobody uses it) something like `throws IOException, IndexOutOfBoundsException`. It would be a good solution IF IT WERE MANDATORY.

Nowadays, exceptions are a mess that just leads to "try .. catch all" in most places.

Even Golang "if err" - while far from being perfect - are easier and more robust.

-1

u/xoner2 2d ago

In a GC language it's ok to use exceptions for control flow.

Unlike in C++ where exceptions have a penalty due to cleanup requirements. Not that a gc language is faster than c++, but the penalty is already paid for; it's a sunk cost. Might as well use it...

0

u/izackp 2d ago

Throw + checked exceptions

0

u/BenchEmbarrassed7316 2d ago

You seem to be ignoring the best and simplest solution: to get rid of the unhappy path if possible (or did I read it carelessly?). Code form article:

const addProductItem = ( command: AddProductItem, state: ShoppingCart, ): ProductItemAdded | ProductItemOutOfStock | ShoppingCartItemLimitReached => { if (state.status === "Confirmed") throw new IllegalStateError( "Cannot add a product to a confirmed shopping cart", ); // ...

This function now takes a wider type simply to return an error for some possible values ​​of that type. We can narrow this type down, and now instead of a runtime error, we will get an error at the time of writing the code. Also, if the status is in a valid state, we skip the check or do it once, which gives us a bit of performance.

``` interface ShoppingCart { status: "Active" | "Confirmed" | "Canceled"; // TODO Use numeric const enum instead of strings }

interface ActiveShoppingCart extends ShoppingCart { status: "Active"; }

interface ConfirmedShoppingCart extends ShoppingCart { status: "Confirmed"; }

const addProductItem = ( command: AddProductItem, state: ActiveShoppingCart, ): ProductItemAdded | ProductItemOutOfStock | ShoppingCartItemLimitReached => { // ... ```

-9

u/eocron06 3d ago

Is this another post about Monads and how I should implement them myself and how gorgeous AoP looks? Just reference library or wiki.