← Back to Blog
TypeScript Comparison between Recoverable errors (typed domain errors: Effect.fail, UserNotFound, ValidationFailed, Forbidden, which belong in the signature) and Defects (out-of-type: Effect.die, DbConnectionLost, NetworkPartition, propagated as a global exception, translated into an HTTP 500).

Errors as values: typed errors vs defects

Not every error belongs in your types. How to separate recoverable domain errors from technical defects, with examples in Effect, Rust, and Go.

📅 ✍️ Antoine Coulon
effecterror-handlingtypescripterrors-as-valuestype-safety

For a long time, I fell into the trap of the Errors as Values concept: I wanted to represent every possible error. When I started using Effect in TypeScript about three years ago, I was completely won over by the idea. But by trying to model everything, I ended up bloating my types with errors that had no business being there.

The lesson I took away from it fits in a single sentence: not every error is meant to be reflected at the type level.

Errors as Values: modeling the error as data

The idea behind Errors as Values is simple and powerful: you model and manipulate errors as ordinary values, rather than as exceptions that travel along a parallel, invisible execution path. The error stops being an event you “catch” somewhere higher up the stack; it becomes a value the type system knows about, one you’re forced to reckon with, and one the compiler helps you handle.

My mistake was pushing the cursor too far: I surfaced everything at the type level, including purely technical errors. But modeling an error as a typed value is only relevant for a subset of them.

Two fundamentally different categories

To get out of the rut, you have to distinguish between two families of errors that share neither the same nature nor the same recipient.

Recoverable errors: typed and explicit

These are the domain errors, the business errors. A consumer can, and should, handle them. They’re part of the contract exposed at the type level: a user that can’t be found, a validation that fails, a resource that’s forbidden given the caller’s rights. In every one of these cases, the calling code has a functional or business alternative to offer: show a message, suggest another action, fall back to a default behavior.

These errors belong in function signatures. That’s precisely their value: they make the contract explicit and force the caller to deal with them.

Unrecoverable errors: defects

These are purely technical errors, dead ends. The consumer can’t do anything with them: no alternative is possible, the system is in an illegal state. A database connection that drops, a network partition, a broken invariant: no one in the call chain has a business answer to provide.

When an error is a defect, you have to cut execution short. Wrapping it in a type to ferry it from layer to layer only forces every intermediary to know about it, for nothing, since none of them is supposed to, or able to, handle it.

The distinction must be provided by the tool

A good Errors as Values ecosystem makes this distinction explicit:

Conversely, any library that claims to do Errors as Values without letting you distinguish between the two is, in my view, incomplete (looking at you, neverthrow in TypeScript): it pushes you to type everything, including what should never be typed.

A concrete example: a request handler

Take an HTTP handler. Losing the connection to the database or to a third-party service is a defect (network partition). The handler has no business knowing about it or handling it: that kind of error should simply propagate as a global exception, then be translated into an HTTP 500 on the server side, without ever polluting the signature of the handler or the business layers.

// ✅ The handler knows about business errors, and only those.
type Handler = Effect<User, UserNotFound | OrganizationNotFound>;
// ❌ The handler can't do anything with the last two errors:
//    they have no business being in its signature.
type Handler = Effect<User, UserNotFound | DbConnectionLost | NetworkFailed>;

In the second case, DbConnectionLost and NetworkFailed force every layer they cross to declare and propagate errors it will never handle. The contract lies: it advertises a responsibility that doesn’t exist.

Conclusion

Typing an error isn’t a technical formality: it’s establishing a contract and a responsibility. It amounts to declaring: “the consumer can and should react to this.” If that statement doesn’t make sense for a given error, then it isn’t a recoverable error: it’s a defect, and its place isn’t in your types, but in an exception you let bubble up to cut things short.

Errors as Values remains a fantastic tool. As long as you don’t forget that its goal isn’t to represent everything, but to represent what’s worth representing.