Why a Result Type
Why a Result Type
throw carries no type information — callers cannot see failure without reading the body, and try/catch cannot distinguish error kinds statically:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function parseConfig(raw: string): Promise<Result<Config, ParseIssue>> {
try {
return { ok: true, value: JSON.parse(raw) as Config };
} catch (err) {
return { ok: false, error: toIssue(err) };
}
}
const r = await parseConfig(text);
if (r.ok) {
r.value; // Config — narrowed by the literal discriminant
} else {
r.error; // ParseIssue
}
The ok: true/ok: false literal types are what make narrowing work; a status: string field would not. Failures become values you must handle, which flows through function signatures and shows up in every call site.
Combinators Without Any
Combinators Without Any
Small generic helpers chain Results fluently:
const map = <T, U, E>(r: Result<T, E>, f: (v: T) => U): Result<U, E> =>
r.ok ? { ok: true, value: f(r.value) } : r;
const andThen = <T, U, E>(
r: Result<T, E>,
f: (v: T) => Result<U, E>,
): Result<U, E> => (r.ok ? f(r.value) : r);
const unwrapOr = <T, E>(r: Result<T, E>, fallback: T): T =>
r.ok ? r.value : fallback;
const fromThrowable = <T>(f: () => T): Result<T, unknown> => {
try { return { ok: true, value: f() }; }
catch (err) { return { ok: false, error: err }; }
};
map transforms success; andThen (flatMap) sequences operations that themselves can fail — the difference mirrors Promise then chains. match collapses to a single output:
const msg = r.ok ? `ok: ${r.value}` : `failed: ${r.error.message}`;
Pitfalls and Interview Angles
Pitfalls and Interview Angles
- Mixed styles: Result plus still-throwing helpers gives double handling — pick per layer (Result for domain failures, exceptions for programmer errors).
- E/E unknown: choose
E = unknownat untrusted boundaries and narrow;E = Errorwhere you own construction. Neverany— it kills the point. - Exhaustiveness: a final
nevercheck catches missed variants when you add a third state:
function assertNever(x: never): never {
throw new Error(`unhandled: ${JSON.stringify(x)}`);
}
switch (r.kind) { case "ok": ...; case "err": ...; default: return assertNever(r); }
- Interop: libraries expect throws — unwrap at the outermost boundary with a policy mapping
Eto status codes.
Interview angle: refactor getUser(id: string): User (throws NotFoundError, NetworkError) into a Result, then show narrowing and the HTTP mapping.
Quiz
1. Why must a Result discriminant be a literal type like ok: true?
2. What distinguishes andThen from map?
3. When are exceptions still appropriate alongside Result types?
Flashcards
Question
Result<T, E>
Click to reveal answer
Answer
{ok:true;value:T} | {ok:false;error:E} — failure as a typed value instead of a throw.
Question
map vs andThen
Click to reveal answer
Answer
map applies T→U to success; andThen applies T→Result<U,E> to sequence fallible steps.
Question
Discriminated narrowing
Click to reveal answer
Answer
if (r.ok) narrows because ok carries the literal true/false, not string.
Question
assertNever
Click to reveal answer
Answer
Takes never; called in the default branch to prove every union member was handled.
Revision Notes
Key Takeaways
- 1. Result makes expected failures visible in signatures and forces handling at call sites
- 2. map/andThen/unwrapOr compose without nesting or any-casts
- 3. Keep one style per layer and unwrap to throws only at framework boundaries
Interview Tips
- • Convert a throwing function live: define the union, show narrowing, then map errors to HTTP codes
- • Contrast Result for domain errors with exceptions for programmer errors — interviewers probe the boundary
Cheat Sheet
Result = {ok:true,value}|{ok:false,error}; narrow via literal ok; map T→U, andThen T→Result; assertNever on default; unwrap at framework edge.