Part 1: Explicit Failure
Errors as values
I don't like to talk about exceptions, but I'll make an exception in this case. The idea seems sound initially. Something went wrong, jump out of here and let someone upstairs deal with it. But that someone upstairs never appears in the type signature, never appears at the call site, and even more so, doesn't appear in our mental model. Exceptions become invisible control flow and once they're invisible, we start using them for anything. Failing to open a file, sure, but also as a glorified return statement five frames deep in the stack. Yes, I've seen this. I might totally never not have done this.
Languages like Go and Rust push back on this by treating errors as values. Unhappy paths show up in signatures, and we actually have to decide what to do with them. But the two offer very different ergonomics. Go's if err != nil is verbose but honest. Rust's Result<T, E> paired with ? is type-safe error propagation with almost none of the noise. Elegant. Java, to its credit, at least lets you put exceptions in the signature i.e. throws SomeException. I hate it but the impulse was at least right.
TypeScript, which is the language I mainly work in, has a type system strong enough to do this well, but it offers no built-in Result and no ? operator to ease the pain.
We can't fix the second part. There's no syntax sugar coming. But the type itself is maybe twenty lines of TypeScript, and once it's there, the ergonomics are better than I expected. It's verbose yes, but the shape of the code becomes the shape of what's actually happening, which is the whole point.
I've been using this pattern for a while. Let's investigate it.
Building the type
TypeScript gives us discriminated unions, and that's all we really need for a simple example. Needless to say, you could get a lot more elaborate but lets keep it simple and focus on the gist of it.
export type ResultSuccess<TData> = {
data: TData;
ok: true;
};
export type ResultFailure<TError> = {
err: TError;
ok: false;
};
export type Result<TData, TError = string> = ResultSuccess<TData> | ResultFailure<TError>;The ok field is the discriminator. Inside a block where ok is true, TypeScript knows data exists and narrows err away. Inside a block where ok is false, the reverse. The reader of the code, and the type system, are looking at the same picture.
A few helpers to keep call sites clean:
export function success<TData>(data: TData): ResultSuccess<TData> {
return {data, ok: true};
}
export function failure<TError>(err: TError): ResultFailure<TError> {
return {ok: false, err};
}
// we'll get back to this function later
export function unwrap<T extends Result<any, any>>(
r: T
): [T] extends [Result<infer TData, any>] ? TData : never {
if (!r.ok) {
if (typeof r.err === "string") {
throw new Error(r.err);
}
throw r.err;
}
return r.data;
}success and failure are obvious. unwrap is the interesting one. It throws. No, I haven't forgotten about my initial rant, unwrap is only for program startup / top-level entrypoints, where failure should crash the process. We will get back to this point later.
The boundary rule
Before we use any of this, the rule that makes the whole pattern work:
Any time you call code that throws, fs.readFile, JSON.parse, a third-party SDK, a database driver, anything you didn't write that could throw, you wrap it once at the boundary and convert the exception to a Result. After that boundary, exceptions don’t exist in your business logic. try/catch becomes something you write at one specific layer, the edges, and nowhere else.
Here's what that looks like in the smallest possible case.
import fs from "fs/promises";
import {success, failure, type Result} from "./result";
async function readFile(file: string): Promise<Result<Buffer>> {
try {
const buffer = await fs.readFile(file);
return success(buffer);
} catch (err) {
// in real code, you'll want to keep the stacktrace
// maybe through a logger but this is just an example..
if (err instanceof Error) return failure(err.message);
return failure("failed to read file: " + file);
}
}
(async () => {
const fileResult = await readFile("./idontexist.txt");
if (!fileResult.ok) {
// narrowed: { ok: false, err: string }
console.log("ERR file err: " + fileResult.err);
return;
}
// narrowed: { ok: true, data: Buffer }
console.log("OK file data: " + fileResult.data.toString());
})();readFile is a boundary adapter. It takes the throwing world (fs.readFile) and produces a Result. Every caller from here on out gets to write code where the unhappy path is a value, not an interrupt.
The if (!fileResult.ok) return; is also quite handy for us besides just being readable. It's how we get type narrowing. After that line, TypeScript knows fileResult.data is a Buffer and won't let us touch err.
Errors with structure
string errors are fine for quick wrappers, but failure often has structure. Let's make a function that doubles a number, but only if the number is in a reasonable range and let's tell the caller exactly which boundary they hit.
const TWICE_ERROR = {
TOO_HIGH: 0,
TOO_LOW: 1,
} as const;
type TwiceError = (typeof TWICE_ERROR)[keyof typeof TWICE_ERROR];
function twice(n: number): Result<number, TwiceError> {
if (n > 100) return failure(TWICE_ERROR.TOO_HIGH);
if (n < 10) return failure(TWICE_ERROR.TOO_LOW);
return success(n * 2);
}The as const object plus keyof typeof is my preferred alternative to TypeScript enums. It produces a plain union of literal values with no runtime weight, plays nicely with switch exhaustiveness, and doesn't have the various enum footguns.
That signature now tells you something useful: twice returns a number on success or one of two specific failure modes. The compiler will yell at you if you forget one in a switch:
const twiceResult = twice(8);
if (!twiceResult.ok) {
switch (twiceResult.err) {
case TWICE_ERROR.TOO_HIGH:
console.log("ERR n too high");
break;
case TWICE_ERROR.TOO_LOW:
console.log("ERR n too low");
break;
}
return;
}
console.log("OK twice: " + twiceResult.data);This is the part that makes the verbosity worth it. You're not just told something failed, you're handed the specific failure as a typed value, and the type system makes sure you've thought about each one.
However, the hard part isn't returning Results. It's preventing error types from exploding into an unreadable taxonomy across layers. That's something we'll explore in Part 2.
Threading Results
The examples so far have been one-shot. Real code calls Result-returning functions from other Result-returning functions, and somewhere up the chain we want to do something useful. Here's what that thread looks like:
async function loadConfig(file: string): Promise<Result<{port: number}>> {
const fileResult = await readFile(file);
if (!fileResult.ok) return fileResult; // propagate
try {
const parsed = JSON.parse(fileResult.data.toString());
return success({port: parsed.port});
} catch {
return failure("config is not valid JSON");
}
}Two things to notice. First, if (!result.ok) return result; is the manual version of Rust's ?. You will write this a lot. It's the price of admission, and TypeScript isn't getting ? any time soon. Second, the JSON.parse call is another boundary, parse can throw, so we wrap it the same way we wrapped readFile.
This is the seam where a more senior version of the codebase starts to live. Repositories wrap the database driver and return Results. Services call repositories and either pass Results through or transform the errors into something more meaningful for their layer. Controllers call services and turn Results into HTTP responses. By the time you're three layers deep, try/catch is nowhere to be seen, it's all at the very edges, where the throwing libraries live.
Am I a hypocrite?
You'll have noticed unwrap throws. After all the ranting at the top of this post, that looks like backsliding. Maybe, but I think it has its place.
Throwing at the top of your program, such as startup, config loading, database connection, i.e. checks that runs before the server starts accepting traffic, is fine. There's no useful caller up there. If your env vars are wrong or your database is unreachable, you don't want to gracefully degrade. You want the process to die loudly so your orchestrator restarts it, or so you notice during local development. unwrap is a deliberate concession to that reality.
The thing the original rant was about, exceptions threading invisibly through business logic, where any callee five frames deep can hijack control flow, is what we still don't do. unwrap lives at the edge of main, not in the middle of a service method. It's the same principle as the boundary rule, just at the top of the stack instead of the bottom: throwing is a thing we permit at exactly the layer where the whole program is allowed to die.
What it costs
After having written code like this for years, you do give up a lot of things. And it costs. You pay a substantial price but I still think it's a worthwhile investment.
Result-returning code is often more verbose than throw-and-catch. You really will write if (!result.ok) return result; over and over. You can build combinators (andThen, map, chain) to compress this, and I did, but these days I leave it explicit. Each layer of abstraction trades clarity for terseness, and the un-abstracted version is a single line of code whose meaning is obvious to anyone who's ever read TypeScript.
What you also don't get is Rust's exhaustiveness on the success side. unwrap lets you cheat, and so does as if you really want to. The type system is opt-in and there are escape hatches everywhere. The pattern works because you commit to it, not because the compiler refuses to compile alternatives.
What you get in return is that errors are part of your interface. A function's signature tells you what can go wrong, and the call site has to acknowledge it, narrow it, propagate it, transform it, or unwrap it and all the wonderful stuff we covered earlier with the boundary rule.
Part 2
In Part 2 we'll scale this up: an Express server with a layered architecture, repositories over Prisma, services over repositories, controllers over services, where Results thread through every layer, each layer's errors mean something specific to that layer, and try/catch lives in exactly one place. The application is a cat adoption site, because the world needs more cats in homes and fewer abstract Foo/Bar examples. See you there.