A tiny Result type
When a function might fail, you have two choices: throw an exception or return a value that represents both success and failure. I spent years reaching for try-catch blocks everywhere until I realized that a tiny Result type—capturing expected failures as values, not exceptions—makes error handling explicit, testable, and honest about what your code can do. In this post, I'll show you how to build and use a Result type that fits naturally into TypeScript's type system, and why it often beats throwing exceptions for cases you actually expect to happen.
Explicit success and failure branches
The core idea is simple: instead of throwing when something goes wrong, return an object that is either a success or a failure. TypeScript's discriminated unions make this elegant.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
// A function that might fail
function parseAge(input: string): Result<number, string> {
const num = Number(input);
if (Number.isNaN(num) || num < 0 || num > 150) {
return { ok: false, error: 'Invalid age' };
}
return { ok: true, value: num };
}
Now the caller sees that parseAge can fail. No hidden exceptions. The function signature says "I might not give you a number"—you get either success with a value or failure with an error message.
const result = parseAge('25');
if (result.ok) {
console.log('Age:', result.value); // value is number
} else {
console.log('Error:', result.error); // error is string
}
TypeScript's type narrowing does the work for you. Inside the if (result.ok) branch, TypeScript knows result.value exists. Inside the else, it knows result.error exists. No type casts, no uncertainty.
Callers must handle errors
With exceptions, it's easy to forget error handling—you just don't write a try-catch and hope nothing breaks. With a Result type, there's nowhere to hide. The compiler forces you to acknowledge both paths.
// Without Result: easy to ignore errors
function getUserAge(userId: string): number {
const user = db.query(`SELECT age FROM users WHERE id = $1`, [userId]);
return user.age; // What if user doesn't exist? Crashes silently or throws.
}
// With Result: must handle both cases
function getUserAge(userId: string): Result<number, string> {
const user = db.query(`SELECT age FROM users WHERE id = $1`, [userId]);
if (!user) {
return { ok: false, error: 'User not found' };
}
if (user.age < 0) {
return { ok: false, error: 'Invalid age in database' };
}
return { ok: true, value: user.age };
}
// Caller must decide what to do
const ageResult = getUserAge('user123');
if (ageResult.ok) {
sendEmail(ageResult.value);
} else {
logError(ageResult.error);
// Must handle this; TypeScript won't let you use ageResult.value here
}
This is a superpower for reliability. You can't accidentally skip error cases. In a large codebase, I've found this catches bugs that would have slipped through code review with try-catch scattered everywhere.
Works great with discriminated unions
Result types shine when you combine them with TypeScript's discriminated union feature. The ok field acts as a tag that TypeScript uses to narrow the type.
type ValidationError = {
field: string;
message: string;
};
type CreateUserInput = {
email: string;
password: string;
};
function validateUser(input: CreateUserInput): Result<CreateUserInput, ValidationError> {
if (!input.email.includes('@')) {
return {
ok: false,
error: { field: 'email', message: 'Invalid email format' }
};
}
if (input.password.length < 8) {
return {
ok: false,
error: { field: 'password', message: 'Password too short' }
};
}
return { ok: true, value: input };
}
// Chaining operations
function registerUser(input: CreateUserInput): Result<{ userId: string }, ValidationError> {
const validationResult = validateUser(input);
if (!validationResult.ok) {
return validationResult; // Pass error up
}
const validated = validationResult.value;
const userId = db.insert('users', validated);
return { ok: true, value: { userId } };
}
You can chain operations: if validation fails, return the error immediately. If it succeeds, pass the validated value to the next step. It's a natural, composable flow that reads like your actual business logic.
To make chaining even smoother, you can add a helper method:
function chain<T, E, U>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
// Usage
const result = chain(
validateUser(input),
(validated) => registerUser(validated)
);
This is essentially what languages like Rust, Go, and Haskell do. It's battle-tested across millions of production systems.
Keep exceptions for bugs
Here's the critical distinction: use Result for expected failures. Use exceptions for unexpected bugs.
- Expected failures: Invalid user input, network timeout, database record not found, payment declined. These happen in normal operation. Return a Result.
- Bugs: Out of memory, null pointer dereference, logic error in your code. These should never happen. Throw an exception and let it bubble up.
// Expected: user provided bad data
function parseEmail(input: string): Result<string, string> {
if (!input.includes('@')) {
return { ok: false, error: 'Missing @' };
}
return { ok: true, value: input };
}
// Expected: database might not have the record
function fetchUser(id: string): Result<User, string> {
const user = db.query(`SELECT * FROM users WHERE id = $1`, [id]);
if (!user) {
return { ok: false, error: 'User not found' };
}
return { ok: true, value: user };
}
// Bug: this should never happen
function processPayment(amount: number): Result<string, string> {
if (amount == null) {
throw new Error('amount is null—this is a bug in the caller');
}
// ... process payment ...
return { ok: true, value: 'success' };
}
In the third example, if amount is null, that's a programming error—something in your code let a null value reach this function. That deserves an exception. It's a signal to your test suite and your monitoring that something went very wrong.
Common mistakes
Mistake 1: Returning Result from everywhere. Not every function needs a Result type. If a function genuinely can't fail (e.g., formatting a number), just return the value. Use Result only when failure is a real possibility.
Mistake 2: Mixing Results and exceptions. Pick one pattern per domain. If you return Results for validation, don't throw exceptions in the same function. It confuses callers about what can fail.
Mistake 3: Returning overly generic errors. Don't return Result<User, string> where the error could be "not found" or "permission denied" or "database down". Use a discriminated union for your error type:
type FetchUserError =
| { kind: 'not_found' }
| { kind: 'permission_denied'; userId: string }
| { kind: 'database_error'; reason: string };
function fetchUser(id: string): Result<User, FetchUserError> {
// ...
}
const result = fetchUser('user123');
if (!result.ok) {
switch (result.error.kind) {
case 'not_found':
return notFound();
case 'permission_denied':
logSecurityEvent(result.error.userId);
return forbidden();
case 'database_error':
logError(result.error.reason);
return serverError();
}
}
This forces callers to handle each failure mode explicitly. It's verbose on purpose—it makes the requirements of your code obvious.
Key takeaways
- A Result type makes error handling explicit: callers see that a function might fail right in the signature.
- Discriminated unions let TypeScript narrow the type automatically, eliminating type casts and runtime surprises.
- Callers are forced to handle both success and failure; you can't accidentally skip error cases.
- Chain Results together to build complex workflows where errors propagate cleanly.
- Use Result for expected failures (bad input, not found, timeout). Use exceptions for bugs (null pointer, logic error).
- Define your error types as discriminated unions so callers know exactly what can go wrong and must handle each case.
I've used this pattern in production APIs, CLI tools, and React apps. It takes discipline at first—you can't just throw and forget—but the payoff in reliability and readability is real. When a senior engineer picks up your code and sees a Result type, they immediately understand what your function promises and what it can fail with. That clarity is worth the extra lines of code.






Comments
Loading comments…