error handling without hidden exceptions
Exceptions in TypeScript are invisible to the compiler: a function that throws looks exactly the same in its signature as one that never fails. The Result type pattern makes failure paths explicit by returning success and failure as a typed value that the caller must actively handle.
Table of Contents
- 1. Why exceptions are a problem in TypeScript
- 2. The core principle of Result types: Ok and Err
- 3. Implementing a generic Result type
- 4. Chaining Result types with map, andThen and unwrap
- 5. Combining Result types with async functions and Promise
- 6. Result types in practice: handling API calls type safely
- 7. Result type versus try/catch: when each pays off
- 8. Common mistakes when using Result types
- 9. Result type pattern in comparison
- 10. Summary
- 11. FAQ
1. Why exceptions are a problem in TypeScript
TypeScript extends JavaScript with a static type system, but the exception mechanism stays entirely outside that system. A function with the signature function parseConfig(input: string): Config claims to always deliver a Config, yet it can internally throw and leave the control flow without the compiler ever noticing. The Result type pattern addresses exactly this: it turns failure into part of the return type, so the caller sees it in the type system and cannot simply ignore it.
The problem gets worse in larger codebases. Whoever calls a function that throws three layers deeper needs to know, from documentation, that this exception exists. TypeScript offers no language feature like checked exceptions from Java, and JSDoc comments with @throws are not verified by the compiler. A Result type solves this documentation problem because the failure case is directly visible in the function signature, and every call site is forced by the compiler to handle both outcomes.
Another aspect concerns performance and the predictability of control flow. Exceptions in JavaScript are meant for truly exceptional situations, not for expected failures such as an invalid user input or a failed network call. Whoever models validation errors through exceptions mixes two different categories: genuine programming bugs and expected business logic outcomes. The Result type pattern separates these cleanly: programming bugs may still throw, expected failures are returned as a value.
2. The core principle of Result types: Ok and Err
At its core, a Result type is a discriminated union of two variants: a success case, usually called Ok, and a failure case, usually called Err. Both variants share a common discriminant field, usually success or ok, that lets TypeScript distinguish the concrete variant at runtime. This idea originally comes from functional languages such as Rust and Haskell, where Result<T, E> and Either respectively are a central language feature for error handling.
The decisive difference from a simple { data: T | null, error: E | null } object lies in exhaustiveness. With a Result type as a real union, a value and an error can never be present at the same time, and the compiler forces a case distinction before value may be accessed. With a loose object holding two optional fields, there are theoretically four states, two of which make no sense, yet the type system never rules them out.
In practice, the Result type is commonly found in libraries such as neverthrow or ts-results, which come with a mature implementation and helper methods. For many projects, though, a minimal custom implementation tailored exactly to the use case is enough and avoids introducing an extra dependency. The following section shows what such a generic Result type looks like from the ground up.
// Core Result type: a discriminated union of Ok and Err
type Ok<T> = { readonly ok: true; readonly value: T };
type Err<E> = { readonly ok: false; readonly error: E };
type Result<T, E> = Ok<T> | Err<E>;
// Constructor functions keep call sites readable
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E>(error: E): Err<E> {
return { ok: false, error };
}
// Usage: the return type documents both outcomes
function parseAge(input: string): Result<number, string> {
const parsed = Number(input);
if (Number.isNaN(parsed) || parsed < 0) {
return err(`"${input}" is not a valid age`);
}
return ok(parsed);
}
const result = parseAge("42");
if (result.ok) {
console.log(result.value); // narrowed to number
} else {
console.error(result.error); // narrowed to string
}
3. Implementing a generic Result type
The minimal definition from the previous section works for simple cases, but a production ready Result type needs additional helper functions that encapsulate typical operations. That includes a safe method to extract the value without manually checking ok, plus a method that supplies a default value on failure. These functions are usually implemented as standalone utility functions rather than class methods, so the values remain plain, serializable objects.
An important detail in the implementation is the naming of the generic parameters. Result<T, E> follows the convention from Rust, where E is often set to string, a custom error object, or a discriminated union of error kinds. Whoever restricts the error type to string loses structure but gains simplicity. Whoever instead uses a custom error union can react to concrete error kinds via switch at the call site, which is especially useful when there are several possible error sources.
Another often overlooked point: the constructor functions ok and err should stay generic enough to also work with void as the success value, for example for a function that only performs a side effect and has nothing meaningful to return on success. TypeScript infers Ok<void> automatically in that case when ok() is called without an argument and the generic parameter is constrained accordingly.
// Utility functions built on top of the core Result type
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.ok;
}
function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return !result.ok;
}
// Safe extraction with a fallback value, never throws
function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
return result.ok ? result.value : fallback;
}
// Extraction that throws only when the caller explicitly asks for it
function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) {
return result.value;
}
throw new Error(`Called unwrap on an Err value: ${JSON.stringify(result.error)}`);
}
// A domain-specific error union instead of a plain string
type ValidationError =
| { kind: "empty"; field: string }
| { kind: "tooShort"; field: string; minLength: number }
| { kind: "invalidFormat"; field: string };
function validateUsername(input: string): Result<string, ValidationError> {
if (input.length === 0) {
return err({ kind: "empty", field: "username" });
}
if (input.length < 3) {
return err({ kind: "tooShort", field: "username", minLength: 3 });
}
return ok(input);
}
4. Chaining Result types with map, andThen and unwrap
The real payoff of the Result type pattern shows up once several failure prone operations need to run in sequence. Without helper functions, a deeply nested chain of if (result.ok) blocks quickly emerges, which severely hurts readability. The method map transforms the success value without touching the failure case, while andThen, sometimes called flatMap, appends another operation that itself returns a Result type.
This chaining follows the same principle as Promise.then, only synchronously and without a microtask queue. As soon as any step in the chain returns an Err, every subsequent map and andThen call is skipped and the original error is passed through to the end of the chain unchanged. That mirrors the behavior known from exceptions, yet it stays fully visible in the type system and forces an explicit handling at the end.
In practice it pays off to offer these combinators as methods on a small wrapper object instead of standalone functions, to improve the readability of chains. Libraries such as neverthrow do exactly that: a Result object with chainable methods that reads almost like a Promise chain, but stays synchronous and free of hidden throwing.
// Chaining combinators for the Result type
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result;
}
function andThen<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
return result.ok ? result : err(fn(result.error));
}
// A realistic pipeline: parse, validate, normalize
function parsePrice(raw: string): Result<number, string> {
const value = Number(raw.replace(",", "."));
return Number.isNaN(value) ? err("Price is not a number") : ok(value);
}
function ensurePositive(value: number): Result<number, string> {
return value > 0 ? ok(value) : err("Price must be positive");
}
function roundToCents(value: number): number {
return Math.round(value * 100) / 100;
}
const priceResult = andThen(
andThen(parsePrice("19.99"), ensurePositive),
(value) => ok(roundToCents(value))
);
if (priceResult.ok) {
console.log(`Final price: ${priceResult.value}`);
} else {
console.error(`Pipeline failed: ${priceResult.error}`);
}
5. Combining Result types with async functions and Promise
Asynchronous operations are the most common place where exceptions stay invisible in TypeScript. A fetch call can fail for many reasons: network errors, timeouts, HTTP status codes outside the success range. Without a Result type, every calling function needs to know it must wrap await in a try/catch block, otherwise the exception propagates unhandled through the entire call chain.
The solution is a ResultAsync type, essentially a Promise<Result<T, E>> that never rejects. Every asynchronous function that could throw internally gets wrapped, and the outcome is translated into ok or err. The caller then awaits a promise that is guaranteed to fulfill and handles the failure case afterward, synchronously, via the discriminated union.
A common misconception is that a Result type must replace exceptions entirely. In practice, an outer safety net with try/catch still makes sense, for example at the boundary to framework code that itself throws, or to catch truly unexpected programming bugs such as a null pointer before the whole application crashes. The Result type covers the expected, documented failure cases, while genuine exceptions stay reserved for the unexpected ones.
// Wrapping a throwing async operation into a safe Result
type ResultAsync<T, E> = Promise<Result<T, E>>;
async function fetchJson<T>(url: string): ResultAsync<T, string> {
try {
const response = await fetch(url);
if (!response.ok) {
return err(`HTTP ${response.status} for ${url}`);
}
const data = (await response.json()) as T;
return ok(data);
} catch (cause) {
// Network errors, JSON parse errors, and CORS failures land here
return err(`Request to ${url} failed: ${String(cause)}`);
}
}
interface Product {
id: number;
name: string;
price: number;
}
async function loadProduct(id: number): ResultAsync<Product, string> {
const result = await fetchJson<Product>(`/api/products/${id}`);
return map(result, (product) => ({
...product,
price: roundToCents(product.price),
}));
}
// Call site: no try/catch needed, the caller always gets a Result
const productResult = await loadProduct(42);
if (productResult.ok) {
renderProduct(productResult.value);
} else {
showErrorBanner(productResult.error);
}
function renderProduct(product: Product): void {}
function showErrorBanner(message: string): void {}
6. Result types in practice: handling API calls type safely
In Magento and Hyva projects, frontend code frequently issues GraphQL or REST calls whose error handling improves noticeably once it consistently builds on the Result type pattern. Instead of equipping every component with its own try/catch, a central API layer wraps all network operations and exposes only Result type values to the outside. The calling component then only needs to handle the two cases, without knowing the internal error details of the HTTP layer.
A practical pattern: form validation and server response are modeled through the same Result type. The client validates locally using a Result<FormData, ValidationError[]> before a request is ever sent. When the response comes back from the server, it gets translated into the same error type, so the UI component that renders error messages does not need to distinguish whether the failure originated locally or on the server.
For checkout flows or payment processing, this explicitness is especially valuable, since an overlooked failure case there has direct financial consequences. A Result type that the compiler forces you to handle prevents exactly the class of bugs where a failed payment silently gets presented as a success because a catch block was forgotten somewhere in the code.
7. Result type versus try/catch: when each pays off
The Result type pattern is not a universal replacement for try/catch, but an additional tool for a specific category of failures. For expected, domain foreseeable failures such as invalid input, failed validation, or expected HTTP error codes, a Result type is the better choice because it makes the failure case visible in the type system and enforces completeness.
For genuinely exceptional situations, meaning cases that by definition should not occur in normal control flow, such as a corrupted internal state or a programming bug, throw still makes sense. Those cases should actually interrupt the application rather than being passed along as a data value, because silently continuing after an inconsistent state is often more dangerous than a clear crash with a stack trace.
A pragmatic rule of thumb: if a failure case would be worth mentioning in the function's documentation, for example a @throws in a JSDoc comment, it is likely a good candidate for a Result type. If the failure case is so unexpected that it would never be documented because it should theoretically never happen, an exception remains the more fitting choice.
8. Common mistakes when using Result types
The most common mistake is introducing the Result type at just one point while the rest of the calling chain keeps enforcing unchecked access. A result.value access without first checking result.ok only works because TypeScript runs in strict mode and the union actually forces narrowing. Without consistent checking along the entire call path, the benefit of type safety disappears, because a single mismatched cast breaks the entire safety net.
// WRONG: bypassing the discriminated union with a type assertion
function unsafeGetValue<T, E>(result: Result<T, E>): T {
return (result as Ok<T>).value; // compiles, but crashes at runtime on Err
}
// WRONG: swallowing the error instead of propagating it
function badHandling(result: Result<number, string>): number {
if (!result.ok) {
return 0; // silently hides the failure, caller cannot distinguish
}
return result.value;
}
// RIGHT: force the caller to decide what a missing value means
function safeHandling(result: Result<number, string>): Result<number, string> {
return result; // propagate unchanged, let the ultimate caller decide
}
// RIGHT: exhaustiveness check with a switch, no assertion needed
function describe<T>(result: Result<T, string>): string {
switch (result.ok) {
case true:
return `Success: ${JSON.stringify(result.value)}`;
case false:
return `Failure: ${result.error}`;
}
}
Another widespread mistake is mixing Result type and exceptions inside the same function. When a function can both return an Err and throw, the caller has to handle both mechanisms at once, which negates the entire benefit of the pattern. Consistency matters more than perfection here: a function should either commit fully to the Result type or fully to exceptions, but never mix both inside the same signature.
9. Result type pattern in comparison
The choice between exceptions, the Result type, and a simple nullable return depends on the concrete use case. The table below compares the three approaches against typical criteria.
| Criterion | Exception (try/catch) | Result Type | Nullable Return |
|---|---|---|---|
| Visible in the type system | No | Yes | Partially |
| Error details transportable | Yes, via error object | Yes, structurally typed | No, presence only |
| Compiler enforces handling | No | Yes, in strict mode | Only with strict null checks |
| Chainability | Cumbersome | map, andThen native | Not supported |
| Suitable for true exceptions | Yes | Conditionally | No |
In mixed codebases it is quite common to use all three approaches in parallel, as long as clear boundaries are defined. A proven convention: domain and business logic consistently use the Result type, while the outermost layer, for example a global error handler in Express or an Alpine.js event listener, catches remaining exceptions and translates them into a unified error display.
Mironsoft
TypeScript architecture, type safe APIs, and robust error handling
TypeScript code that does not swallow errors?
We build type safe error handling with the Result type pattern, custom error class hierarchies, and clean API layers for your Magento, Hyva, and Node.js stack.
Architecture Review
Analysis of existing error handling and a migration plan toward Result types
API Layer
Central, type safe API wrappers with ResultAsync for your frontend
Team Training
Workshops on functional error handling and TypeScript patterns
10. Summary
The Result type pattern makes failure paths in TypeScript visible where exceptions stay invisible. Instead of throwing an exception, a function returns a discriminated union of Ok and Err that the caller must actively handle before accessing the success value. Helper functions such as map, andThen, and unwrapOr allow readable chaining of several failure prone steps, similar to a promise chain, but synchronous and without hidden throwing.
In asynchronous code, a ResultAsync, essentially a never rejecting promise, wraps all network failures into a typed outcome. What matters is the clear separation: the Result type is meant for expected, documentable failures, while genuine exceptional situations should still be handled via throw. Whoever keeps both mechanisms strictly apart gains readable, type safe code where no failure case slips through unnoticed.
Result Type Pattern in TypeScript, the essentials at a glance
Core Principle
Result<T, E> = Ok<T> | Err<E>, a discriminated union that anchors failure paths in the type system instead of hiding them.
Chaining
map and andThen chain failure prone steps without nested if blocks, similar to a promise chain.
Async
ResultAsync as a never rejecting promise wraps network failures and replaces scattered try/catch blocks.
Boundary
Expected errors via Result type, genuine programming bugs still via throw, never mix both inside the same function.