loading...

re: Type-Safe Error Handling In TypeScript VIEW POST

FULL DISCUSSION
 

I think you have basically a good idea, but taking it to an extreme like this is not useful, IMO. I.e.:

Users of your API are not required to catch (the compiler
doesn't enforce it). This means you will eventually hit a
runtime error ... it's just a matter of time

True, but even with result ADTs, it's still just a matter of time, because runtime errors will always happen. You've changed your one "if not url, return the err result" line to not throw, but what about all of the code your function calls? I.e. you call randomNpmLibrary.whatever() or what not.

To literally never throw a runtime exception, you'd have to wrap your function implementation with a try catch:

try {
  ... regular code...
  if (boundaryCondition(...)) { return err(...);
  return result(...);
} catch (e) {
  return err(...); // something i called failed
}

Anytime you call code (either yours or 3rd party) that a) hasn't moved over to the error ADT code style, or b) isn't 100% perfect in its adherence to the ADT code style and still accidentally NPEs/runtime exceptions when it isn't supposed to.

So, I think chasing "no runtime exceptions" is just a false/unattainable premise.

The other pitfall is that, if you want to represent "all failures return error instead of throw", eventually your ADT's error type expands and expands until they become super generic Result<Data | AnyError>. And you've lost any notion of the type-safety that you were after.

The reason is exactly what happened to checked exceptions; you start with Result<Data, DbError>. But now there is also an error if the user passes the wrong data, so you do Result<Data, DbError | ConfigError>. Now all your callers have to match on the new error (which we assert is good), or, they need to pass the new | ConfigError up to their callers, i.e. bubble up the error. So you're updating a lot of return values to do "oh right, add | ConfigError for my new error condition".

Which is exactly what happened with checked exceptions, having to add "oh right, also throws XyzException" all over your codebase whenever you discovered/added code for a new error condition.

So, to avoid updating all those places, maybe you make a more generic error type, like Result<Data, DataLayerError>, but now you need to convert all non-DataLayerErrors (like NoSuchMethod/etc.) into a DataLayerError, which is exactly like what ServiceLayerExceptions in checked exceptions did. :-)

Basically, at some point its actually a feature that "a random error can happen, it will blow up the stack, and you can catch it at some point on top".

Granted, for certain things, like network calls which are both extremely important and also almost certain to fail at some point, it makes a lot of sense to represent those as ADT return values, and force your caller to handle both success + networkerror. And so you're exactly right that it's a useful pattern to use there.

But I just wanted to point out that that I think taking it to the extreme of "my functions will never throw" and "I will use ADTs to represent any/all error conditions" is a slippery slope to a leaky abstraction, where Result<Data, Error> return values (i.e. its now very generic b/c it has to represent all failures, so how type-safe is it?) are doing essentially the same thing as catch but in a manner that is inconsistent and non-idiomatic with the runtime/rest of the ecosystem.

 

Thanks for your explanation, Stephen. Your words exactly describe my opinion.

code of conduct - report abuse