← 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.

import { Effect, Data } from "effect";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform";

export class UserNotVerified extends Data.TaggedError("UserNotVerified")<{
  userId: string;
}> {}

export const getUser = (
  userId: string,
): Effect.Effect<User, UserNotVerified> => {
  return Effect.fail(new UserNotVerified({ userId }));
};

export const someRouter = <R, E>(router: HttpRouter.HttpRouter<R, E>) =>
  router.pipe(
    HttpRouter.get(
      "/users/:userId",
      pipe(
        Effect.gen(function* () {
          const userId = yield* HttpRouter.getParam("userId");
          const user = yield* getUser(userId);
          return yield* HttpServerResponse.json(user, { status: 200 });
        }),
        Effect.catchTags({
          UserNotVerified: (error) =>
            HttpServerResponse.json(
              { error: "user_verified" },
              { status: 403 },
            ),
        }),
      ),
    ),
  );

In that case, UserNotFound is part of the expected flow. Domain experts know it can happen part of the journey. UserNotFound is kind of generic in that example, but imagine real specific errors in rich domains. We call those recoverable errors as we expect the caller to be able to handle it, or propagate it to someone who can.

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.

A very good example of such error is the runtime configuration. Let’s say you have a runtime configuration that is required to be present. If it’s not, you can’t start the application. This is a defect because you can’t recover from it. You have to fail fast and let the system crash.

import { Effect } from "effect";

export const startApplication = Effect.gen(function* () {
  //                      ^ Effect.Effect<string, never> "never" because only a defect can happen here
  const url = yield* Config.get("DATABASE_URL");

  // Later on...
  yield* startHttpServer(config);
});

If Config.get fails, we can’t start the application and we should just crash. We should not be expecting anything else to happen here. Short circuiting the execution is the right thing to do, hence no typed error should appear in Config.get, otherwise it delegate the responsibility to handle the error to the caller.

And now is the time when you wonder how is that different from just throwing exceptions. The fair point is that exceptions mimic the type-level behavior we expect from defects, that is they don’t pollute the type system. However, an exception itself is not enough to give us the guarantee we need. It is really simple to catch an exception unvoluntarily and swallow it, as we might have try/catch blocks to deal with expected errors as well. In other words, we use the same mechanism to deal with expected errors as we do with unexpected errors, which is very brittle.

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. neverthrow is a good example highlighting the limits of this approach as it pushes you to type everything, including what should never be typed.

I have seen huge Result types combining Domain Errors and Defects in the same type, which is a clear sign of a wrong approach. One could be tempted to cheat, by using ResultAsync.fromSafePromise and then providing a Promise.reject so that neverthrow crashes, but this is a very brittle solution as again, this can be easily circumvented by catching the exception and swallowing it. We need stronger guarantees to be sure that we are not swallowing a defect.

Another 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.

import { Effect } from "effect";

// ✅ The handler knows about business errors, and only those.
type GoodHandler = Effect<User, UserNotFound | OrganizationNotFound>;

// ❌ The handler can't do anything with the last two errors:
//    they have no business being in its signature.
type BadHandler = 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.