Lesson 63 of 80
A Result<T, E> return type puts failure in the type signature instead of hiding it in a throw — the compiler can then force every caller to actually handle it.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
throw in TypeScript has a well-known hole: nothing in a function's type signature says it can throw, or what it throws. catch (e) gives you e: unknown — deliberately, because the language has no way to guarantee what actually gets thrown. The compiler can't force a caller to handle an error it doesn't know exists.
A Result type moves failure into the return type instead. Result<T, E> is a discriminated union — the same tool from the discriminated unions lesson — that's either a success carrying a T or a failure carrying an E. A function that returns Result<User, ParseError> is telling every caller, right there in the signature, that it can fail and exactly what the failure looks like. There's no way to reach the T without going through a check the compiler can verify you performed.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
type ParseError = { reason: "empty" | "not-a-number" };
function parseAge(input: string): Result<number, ParseError> {
if (input.trim() === "") return err({ reason: "empty" });
const n = Number(input);
if (Number.isNaN(n)) return err({ reason: "not-a-number" });
return ok(n);
}
const result = parseAge("42");
if (result.ok) {
console.log(result.value + 1); // value: number — only reachable after the check
} else {
console.log(result.error.reason); // error: ParseError — only reachable in this branch
}
Try accessing result.value before checking result.ok and TypeScript rejects it — value only exists on the { ok: true } branch of the union, and the compiler has no way to know which branch you have until you narrow it. That if (result.ok) check is doing the same job try/catch does, except the compiler can actually see whether you wrote it.
throw is invisible to the type checker; Result isn't. A function typed (input: string) => number might throw, might not — nothing in the signature says so, and TypeScript will never warn a caller who forgot a try. A function returning Result<number, ParseError> makes the failure path part of the contract.throw for bugs, Result for outcomes..ok doesn't silently succeed. Accessing .value without narrowing is a compile error, not a runtime undefined — that's the entire value proposition over a plain { data, error } object where both fields exist unchecked and nothing stops you reading the wrong one."What's the actual difference between throwing and returning a Result type?" is a real design question, not trivia — a strong answer explains that throw bypasses the type system entirely while Result puts the failure path through the same exhaustiveness checking as any other discriminated union, and can name a case where each approach is the right call.