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.

6 min read

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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

Result pattern flow

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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

typescripttypescript
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.

Aspecttry/catchResult pattern
Failure visible in signatureNoYes
Compiler enforces handlingNoYes
Works with asyncYesYes
Fits expected failuresClunkyNatural
Fits unexpected crashesNaturalClunky

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.

typescripttypescript
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.

typescripttypescript
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

SituationPrefer
Parsing user inputResult
Validating form dataResult
Making an API callResult
Reading a fileResult
A programming bugThrown error
Corrupted internal stateThrown error
Missing required configurationThrown 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

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.
RunePowered by Rune AI

Frequently Asked Questions

How is the Result pattern different from try/catch?

With try/catch, the error path is invisible in the function signature. The caller might forget to handle errors. With Result, the return type explicitly says 'this can fail,' and TypeScript forces the caller to handle both success and failure before accessing the value.

Does the Result pattern work with async code?

Yes. An async function can return Promise of Result, which makes the potential failure visible in the return type. The caller must handle both cases after awaiting, just like with synchronous Results.

When should I use Result instead of throwing errors?

Use Result when failure is expected and the caller should handle it explicitly, such as parsing user input, validating data, or fetching from APIs. Use thrown errors for truly exceptional situations where the caller cannot reasonably recover.

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.