type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseJSON<T>(raw: string): Result<T> {
try {
return { ok: true, value: JSON.parse(raw) as T };
} catch (error) {
return { ok: false, error: error as Error };
}
}
const good = parseJSON<{ id: number }>('{"id":42}');
if (good.ok) {
console.log(good.value.id); // 42, fully typed
}
const bad = parseJSON('{ broken');
if (!bad.ok) {
console.log(bad.error.message); // a SyntaxError message
}42
Unexpected end of JSON inputOpen this snippet in the editor
Launches a fresh code space with this TypeScript already loaded — edit it, share the link, or keep building.
How it works
A `Result` is a discriminated union: the `ok` boolean is the discriminant, and TypeScript narrows the type the moment you check it. Inside `if (good.ok)`, the compiler knows `good.value` exists and `good.error` does not — and in the `else` branch it knows the opposite. You simply cannot read `value` without first proving success, and that guarantee is enforced at compile time.
The point is to push failure into the function's signature instead of hiding it in an invisible `throw`. A caller reading `Result<User>` sees immediately that the call can fail and that they're expected to handle it. With exceptions, that obligation is undocumented: nothing in `getUser(): User` tells you it might throw, so the handling gets forgotten until it crashes in production.
The pattern comes from Rust's `Result` and the ML family's `Either`, and it shines for parsing, validation, network calls, and file I/O — anywhere a caller realistically needs to deal with the error path rather than let it bubble up to a top-level handler. For truly unrecoverable programmer errors (a failed invariant, a bug), a plain `throw` is still the right tool; Result is for *expected* failures.
Used consistently, Result composes. A function can call several Result-returning helpers, short-circuit on the first failure, and return a Result of its own — so error handling reads as a straight line rather than a tangle of try/catch. The cost is discipline: a layer should either return Result or throw, not both, so callers always know what to expect.
Variations
Constructor helpers
Tiny ok() and err() helpers make Result-returning code read cleanly and keep the literal shape in one place.
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
function half(n: number): Result<number, string> {
return n % 2 === 0 ? ok(n / 2) : err("not even");
}Async Result
Wrap a promise so network failures become values instead of rejections the caller has to remember to catch.
async function getUser(id: string): Promise<Result<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
return { ok: true, value: await res.json() };
} catch (error) {
return { ok: false, error: error as Error };
}
}A call site that has to handle both paths
Because the failure is in the type, the compiler won't let the caller reach the value without checking. That single constraint eliminates a whole category of "forgot to handle the error" bugs.
const result = await getUser("42");
if (!result.ok) {
showToast(result.error.message); // must handle this first
} else {
renderProfile(result.value); // value is guaranteed here
}Common mistakes & good to know
- Casting is a trust boundary: `JSON.parse(raw) as T` assumes the input matches T. For untrusted data, validate the shape (e.g. with Zod) before claiming the type.
- Always check `ok` before touching value or error — skipping it defeats the pattern, and the union won't narrow so the compiler stops you anyway.
- Don't mix styles in one layer. Decide whether a module returns Result or throws and keep it consistent, so callers know what to expect.
- Result is for expected failures. For genuine bugs and broken invariants, a throw is still clearer than threading a Result nobody can recover from.
Frequently asked questions
Why not just throw exceptions?
Exceptions are invisible in the type signature, so callers can forget to handle them until it crashes. A Result puts the failure in the return type, and TypeScript forces you to check before using the value.
How does the type narrowing work?
Result is a discriminated union keyed on the ok boolean. Checking if (result.ok) tells the compiler which branch you're in, so value is available on success and error on failure — and not the other way around.
Is there a library for this?
Yes — neverthrow and fp-ts provide richer Result/Either types with map, andThen, and combinators. The hand-rolled type here is enough for most codebases and has zero dependencies.
Should everything return Result?
No. Use it for expected, recoverable failures — parsing, validation, I/O. For unrecoverable programmer errors, a plain throw is clearer.
Related snippets
Next
Binary search in Python