Result Pattern in TypeScript
Learn the Result pattern as an alternative to throwing errors. Model success and failure as plain values so the compiler forces you to handle both cases.
The Result pattern replaces thrown errors with a return value that explicitly models both success and failure. Instead of a function returning a value or throwing, it returns an object that is either a success carrying the value or a failure carrying the error. The caller must check which one they got before using the result.
This shifts error handling from an invisible side-path (try/catch) into the normal return type, where the TypeScript compiler can enforce that both cases are handled.
Defining the Result Type
A Result is a discriminated union with two variants: success and failure. The ok property is the discriminant that TypeScript uses to narrow the type.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };When the discriminant is true, the object carries a success value. When it is false, the object carries an error instead. TypeScript uses that single boolean to narrow which branch you are in.
Here is the simplest version in action. A function returns a Result instead of throwing.
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { ok: false, error: new Error("Cannot divide by zero") };
}
return { ok: true, value: a / b };
}The return type means the function either succeeds with a number or fails with an Error object. The caller handles both outcomes explicitly instead of wrapping the call in a try block.
const result = divide(10, 2);
if (result.ok) {
console.log("Result:", result.value);
} else {
console.error("Error:", result.error.message);
}Inside the if block, TypeScript knows the value property exists. Inside the else, it knows the error property exists instead. There is no way to access the success value without first checking ok.
The diagram shows the single branch point every Result caller must pass through. The compiler guarantees both paths are handled.
Helper Functions for Cleaner Construction
Writing out the success and failure object shape by hand every time gets repetitive. Two small helper functions make construction readable.
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}Now the divide function reads more naturally, since each branch calls a small named helper instead of building the success or failure object by hand.
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return err(new Error("Cannot divide by zero"));
}
return ok(a / b);
}The helpers also improve type inference. Calling ok(42) lets TypeScript infer a success Result with a never error type, which is assignable anywhere a Result with a matching value type is expected.
Chaining Operations
Real functions rarely work in isolation. You often need to call one function, check its result, then call another. A map helper transforms the success value while passing errors through unchanged.
function mapResult<T, U, E>(
result: Result<T, E>,
transform: (value: T) => U
): Result<U, E> {
if (result.ok) {
return ok(transform(result.value));
}
return result;
}Use it to chain operations without nested if-checks, since a single early return handles the failure case before the transform ever runs.
function parseAndDivide(input: string): Result<number> {
const parsed = parseNumber(input);
if (!parsed.ok) return parsed;
return mapResult(parsed, (num) => num / 2);
}Errors propagate automatically through the helper. The success path gets transformed. The error path passes through untouched.
For chaining operations that themselves return a Result, a flatMap-style helper avoids nesting one Result inside another.
function flatMapResult<T, U, E>(
result: Result<T, E>,
transform: (value: T) => Result<U, E>
): Result<U, E> {
if (result.ok) {
return transform(result.value);
}
return result;
}This is useful when the next step can also fail, because the helper unwraps the nested Result instead of leaving you with a Result inside a Result.
function validateAndSave(input: string): Result<User> {
const validated = validateInput(input);
return flatMapResult(validated, saveUser);
}Each step returns a Result. If any step fails, the error propagates and the remaining steps are skipped.
Comparing Result to try/catch
The key difference is visibility and enforcement.
| Aspect | try/catch | Result pattern |
|---|---|---|
| Failure visible in signature | No | Yes |
| Compiler enforces handling | No | Yes |
| Works with async | Yes | Yes |
| Fits expected failures | Clunky | Natural |
| Fits unexpected crashes | Natural | Clunky |
With try/catch, nothing in the function signature tells you it might throw. You discover errors at runtime. With Result, the return type spells out both outcomes, and TypeScript forces every caller to acknowledge the possibility of failure.
Async Results
The pattern works identically with async functions. Return a Promise of a Result and the caller checks ok after awaiting.
async function fetchUser(id: string): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return err(new Error(`HTTP ${response.status}`));
}
const data = await response.json();
return ok(data as User);
} catch (error) {
return err(new Error("Network request failed"));
}
}The caller awaits the promise, then narrows exactly like the synchronous version, since the ok check works the same whether the Result arrived synchronously or through a resolved promise.
const result = await fetchUser("42");
if (result.ok) {
console.log("User:", result.value.name);
} else {
console.error("Failed:", result.error.message);
}The async boundary does not change the pattern. The Result is the same, just wrapped in a Promise.
When to Use Result vs Throwing
| Situation | Prefer |
|---|---|
| Parsing user input | Result |
| Validating form data | Result |
| Making an API call | Result |
| Reading a file | Result |
| A programming bug | Thrown error |
| Corrupted internal state | Thrown error |
| Missing required configuration | Thrown error |
Use Result when failure is expected and recoverable. The caller should always handle these failures as part of normal control flow.
Use thrown errors for genuinely unexpected situations where the caller cannot reasonably recover. These should crash the operation or be caught by a top-level handler instead of a local check.
For a broader comparison of both approaches, see throwing errors vs Result types in TypeScript. For handling caught errors safely, see unknown errors in TypeScript.
Rune AI
Key Insights
- A Result type is a union of { ok: true; value: T } and { ok: false; error: E }.
- The compiler forces callers to check ok before accessing value or error.
- Create helper functions like ok() and err() to construct Results concisely.
- Use Result for expected failures and thrown errors for unrecoverable situations.
- Async Results work the same way with Promise of Result.
Frequently Asked Questions
How is the Result pattern different from try/catch?
Does the Result pattern work with async code?
When should I use Result instead of throwing errors?
Conclusion
The Result pattern makes failure a first-class part of your function signature. Instead of hiding the error path in try/catch, the return type tells the caller 'this can fail' and the compiler enforces handling both outcomes. For expected failures like validation or network errors, it produces safer and more readable code than throwing.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.