Context & Why: The Hidden Cost of try-catch
Early in my TypeScript journey, I wrote functions that looked clean on the surface but were actually landmines waiting to go off. Something like this:
async function fetchUserProfile(userId: string) {
try {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
return data;
} catch (err) {
console.error(err);
return null;
}
}
The function signature says it returns user data. But it can silently return null. The caller has no idea a failure is possible — until something blows up in production at 2 AM.
Therein lies the core problem with try-catch in TypeScript: errors are invisible in the type system. TypeScript can tell you a lot about your code — but it won’t warn you that you forgot to handle a failure case, because the failure never appears in the function’s return type.
The Result Pattern fixes this by encoding errors directly into the return type. Instead of throwing exceptions or returning null, a function returns either a success value or a typed error — and the type system forces the caller to handle both. No more forgetting.
In practice, this pays off fast. Around 500–1000 lines of code, teams start losing track of which functions can silently fail. The Result Pattern makes that knowledge impossible to miss — it’s in the types.
What the Result Pattern Looks Like
At its simplest, the pattern is just a discriminated union:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
A function that can fail returns Result<User, ApiError> instead of User | null. Now TypeScript knows the failure is possible. It will not let you use the value without first checking ok.
Installation: Picking Your Approach
Two options: roll your own lightweight Result type, or pull in an existing library. Both work — pick based on how much you need out of the box.
Option A: Build It Yourself (Recommended for Learning)
Start a fresh TypeScript project if you do not have one:
mkdir result-pattern-demo
cd result-pattern-demo
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init
Then create a src/result.ts file with your core types and helper functions:
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
Two helper functions — ok() and err() — are all you need to start returning Results from any function.
Option B: Use neverthrow
Want chaining, async support, and more utilities out of the box? neverthrow is the most widely used Result library in the TypeScript ecosystem:
npm install neverthrow
It gives you the same ok() and err() pattern plus extras like .map(), .mapErr(), and ResultAsync for promise-based workflows. For this tutorial, the hand-rolled version is enough to understand the core idea.
Configuration: Wiring the Pattern Into Real Code
Now for the interesting part — rewriting your service layer to use Results instead of throwing exceptions.
Step 1: Define Your Error Types
One of the biggest wins from this pattern is typed errors. Instead of catching an unknown Error, you define exactly what can go wrong:
// src/errors.ts
export type ApiError =
| { type: 'NOT_FOUND'; message: string }
| { type: 'UNAUTHORIZED'; message: string }
| { type: 'NETWORK_ERROR'; message: string; statusCode?: number };
Every caller now knows upfront which error cases to handle. No more guessing.
Step 2: Rewrite Your Service Functions
// src/userService.ts
import { Result, ok, err } from './result';
import { ApiError } from './errors';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(userId: string): Promise<Result<User, ApiError>> {
try {
const res = await fetch(`https://api.example.com/users/${userId}`);
if (res.status === 404) {
return err({ type: 'NOT_FOUND', message: `User ${userId} not found` });
}
if (res.status === 401) {
return err({ type: 'UNAUTHORIZED', message: 'Invalid or expired token' });
}
if (!res.ok) {
return err({
type: 'NETWORK_ERROR',
message: 'Unexpected API error',
statusCode: res.status,
});
}
const user: User = await res.json();
return ok(user);
} catch (e) {
return err({ type: 'NETWORK_ERROR', message: 'Could not reach API' });
}
}
try-catch still lives here — but it’s isolated at the boundary where the unpredictable thing actually happens: the network call. The rest of your application never needs try-catch again.
Step 3: Handle Results at the Call Site
Here’s where it clicks. The caller gets a clean, predictable interface:
// src/index.ts
import { fetchUser } from './userService';
async function main() {
const result = await fetchUser('user-123');
if (!result.ok) {
// TypeScript narrows result.error to ApiError here
switch (result.error.type) {
case 'NOT_FOUND':
console.log('Show a 404 page:', result.error.message);
break;
case 'UNAUTHORIZED':
console.log('Redirect to login:', result.error.message);
break;
case 'NETWORK_ERROR':
console.log(`Retry later. Status: ${result.error.statusCode}`);
break;
}
return;
}
// TypeScript knows result.value is User here — no casting needed
console.log(`Welcome, ${result.value.name}!`);
}
main();
Add a new error type to ApiError later, and TypeScript highlights every switch statement that forgot to handle it. That is compile-time safety you simply cannot get from try-catch.
Step 4: Composing Results Across Layers
Say you fetch a user, then fetch their orders. Both can fail. With Results, chaining them is straightforward — no nested try-catch blocks required:
async function getUserWithOrders(userId: string) {
const userResult = await fetchUser(userId);
if (!userResult.ok) return userResult; // propagate the error up
const ordersResult = await fetchOrders(userResult.value.id);
if (!ordersResult.ok) return ordersResult;
return ok({
user: userResult.value,
orders: ordersResult.value,
});
}
Each step only continues if the previous one succeeded. If anything fails, the error bubbles up as a typed value — not an exception that might get swallowed somewhere in the call stack.
Verification & Monitoring: Making Sure It Works
Run a Quick Smoke Test
Run your entry point with ts-node to verify the happy path and error paths both behave correctly:
# Run the script
npx ts-node src/index.ts
# Expected output (happy path):
# Welcome, Jane Doe!
# Expected output (not found):
# Show a 404 page: User user-999 not found
Validate TypeScript Catches Missing Cases
Try adding a new error type to ApiError without updating the switch statement:
export type ApiError =
| { type: 'NOT_FOUND'; message: string }
| { type: 'UNAUTHORIZED'; message: string }
| { type: 'NETWORK_ERROR'; message: string; statusCode?: number }
| { type: 'RATE_LIMITED'; message: string; retryAfter: number }; // NEW
Run the TypeScript compiler:
npx tsc --noEmit
With a default exhaustiveness check in your switch (shown below), the compiler immediately flags the missing case:
function assertNever(x: never): never {
throw new Error('Unhandled case: ' + JSON.stringify(x));
}
// Inside your switch:
default:
assertNever(result.error); // Compile error if a case is missing
The compiler becomes your exhaustiveness checker — it flags the missing case before the code ships, every time, without needing a reviewer to catch it.
Logging and Observability
Errors are now plain objects in the return flow. Adding structured logging is trivial:
const result = await fetchUser(userId);
if (!result.ok) {
// Log as structured data — easy to ship to Datadog, Sentry, etc.
console.error(JSON.stringify({
event: 'fetch_user_failed',
errorType: result.error.type,
userId,
timestamp: new Date().toISOString(),
}));
}
Compare this to catching an unknown Error object and trying to serialize it for a logging system. Typed errors make observability significantly cleaner.
One Thing to Watch Out For
The Result Pattern does not replace try-catch everywhere — it replaces uncontrolled propagation. You still need try-catch at your outermost boundary: the network call, the database query, the file read. Catch there, convert the exception to a Result, and let Results flow cleanly through the rest of your app.
A good rule of thumb: if a function touches external I/O, wrap it in try-catch and return a Result. If it only calls your own functions, propagate Results — no try-catch needed.
Used consistently, the Result Pattern makes code easier to read, easier to test, and much harder to break silently. The type system enforces discipline that code reviews routinely miss.

