Result types
Return failure instead of throwing it. The compiler then tells you what to do about it.
You write a function that parses a date string. Most strings work. A few don't. You decide to throw on the bad ones because that's what every tutorial does. Three weeks later, a teammate calls your function from a route handler and forgets to wrap it in try. The bad input crashes the request. The error is logged but no one looks. The user sees a 500. The cost of a bug becoming an outage is paid because the type system never said this might fail.
The Result shape
A Result is a discriminated union with two arms: success and failure. The success arm holds the value; the failure arm holds an error. Returning a Result makes failure visible in the function's signature — every caller has to deal with both arms or the compiler complains.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };ok is the discriminant. When ok is true, value is the successful result. When ok is false, error carries the reason. The shape is small enough to memorize and live in a one-liner at the top of any module that wants it.
A function that used to throw can change its return type to Result<T, E> and stop throwing. The signature becomes honest — this function returns a value or it returns a failure, and the caller has to choose.
function parseDate(input: string): Result<Date, string> {
const ms = Date.parse(input);
if (Number.isNaN(ms)) return { ok: false, error: "not a date: " + input };
return { ok: true, value: new Date(ms) };
}Two return points, both labeled. There's nothing for try/catch to catch — the function never throws. The caller can no longer pretend the failure case doesn't exist. They have to look at ok.
Using a Result
The caller pattern is a single if on ok. Inside the true branch, value is a Date. Inside the false branch, error is the string. The discriminated union you saw in lesson 1 is doing the work — narrowing on ok exposes the right field on each side.
const r = parseDate(req.query.from);
if (!r.ok) {
res.status(400).json({ message: r.error });
return;
}
const from: Date = r.value;
console.log("parsed", from.toISOString());The early return on the false arm is a useful habit — handle the failure, return, and from then on the type narrows to the success arm. The line const from: Date = r.value only compiles because the early return removed the failure case from consideration.
If the caller writes const from: Date = r.value without checking r.ok, the compiler rejects it. r.value doesn't exist when r.ok is false. The pattern keeps the caller honest by construction.
A small set of helpers tend to grow around Result. Two are common enough to be worth showing.
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
function map<T, U, E>(r: Result<T, E>, f: (v: T) => U): Result<U, E> {
return r.ok ? ok(f(r.value)) : r;
}ok(value) and err(error) save a few keystrokes and make call sites easier to read. map applies a function to the success arm and leaves the failure arm alone. With these three pieces you can chain transformations without ever sneaking a throw in.
if (!r.ok) { ... return; }, what is the type of r on the line right after the closing brace?vs throwing
Both patterns describe failure. The choice between them is about where the failure goes and what the caller has to do about it.
throw punches a hole through every function up the stack until something catches. The signature of the function doesn't say it might throw — TypeScript has no throws clause. Callers can ignore the failure mode entirely, and many do. When the failure is rare and unrecoverable — out of memory, programmer mistake, invariants violated — that hole is the right behavior. Crashing loud is the appropriate response to "this should never happen."
Result puts the failure in the return type. The caller cannot ignore it without a cast. The signature itself documents that this can fail, and how. When the failure is expected — bad input, network 4xx, validation rejection — that visibility is the point.
/* throwing flavor */
function getUserT(id: string): User {
const u = db.find(id);
if (!u) throw new Error("not found");
return u;
}
/* result flavor */
function getUserR(id: string): Result<User, "not_found"> {
const u = db.find(id);
return u ? ok(u) : err("not_found");
}getUserT has a clean return type but a hidden failure mode. A caller who reads only the signature thinks they always get a User. getUserR looks busier but tells the truth: the call may fail, and the failure tag is "not_found". The compiler will refuse code that uses r.value without checking r.ok first.
The literal string error tag ("not_found") is more useful than a generic Error. Different failures get different tags, and switching on them at the call site is exhaustive — same trick as lesson 4.
Where each one fits
A useful boundary: expected failures get a Result; unexpected ones throw. Validation, parsing, network calls, business rules that can legitimately reject input — all Result. Programmer errors, broken invariants, "this assertion should always hold" — throw. The split keeps try/catch reserved for the kind of failure that genuinely is an exception, not the everyday no.
Result shines hardest in two places. Pure functions that can fail (like parsers) are the cleanest fit — the caller is right next to the call. Functions that fail in several distinct ways benefit too, because the error type can be a union of tags — "not_found" | "forbidden" | "rate_limited" — and the caller can switch on it.
type ApiError = "not_found" | "forbidden" | "rate_limited";
function loadProfile(id: string): Result<Profile, ApiError> {
/* ... */
return err("rate_limited");
}
const r = loadProfile("u_1");
if (!r.ok) {
switch (r.error) {
case "not_found": return show("Profile missing");
case "forbidden": return show("Access denied");
case "rate_limited": return retryLater();
}
}The switch is exhaustive — pair it with assertNever (lesson 4) and adding a new error tag breaks the build at every consumer.
Result doesn't compose as quietly as throwing, though. Long chains of operations where each can fail end up writing if (!r.ok) return r over and over, or reaching for helper functions like map and andThen. In a deeply nested call chain, throwing through several layers can be less code. Pick Result when the next layer wants to handle the failure, not when the failure has to bubble through eight stack frames.
Don't return Result and also throw. The signature lies, the caller can't tell which to handle, and the compiler stops being able to help. Pick one mode per function.
Result over throwing?Result<User, "not_found" | "forbidden">. The caller writes if (!r.ok && r.error === "not_found") render404(); and uses r.value below. What does the compiler say?throw new Error("rate limit") inside a function that returns Result<T, ApiError>. What's wrong?