Type-Safe Error Handling: Result<T,E> Pattern
The Type System Blind Spot in try/catch
Before TypeScript 4.0, catch clause variables were implicitly any. 4.0 upgraded them to unknown โ an improvement, but that just means you must narrow the type before using the error. The deeper problem remains: errors are invisible in function signatures.
// Signature tells you this returns User
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // invisible!
const user = await res.json();
if (!user) throw new Error("User not found"); // invisible!
return user;
}
// Caller: without reading the source, you don't know what can throw
try {
const user = await getUser("123");
} catch (e) {
// e is unknown โ is it NetworkError? NotFoundError? ValidationError?
if (e instanceof Error) {
console.error(e.message); // works, but you've lost the error type
}
}
The signature Promise<User> is silent about possible errors. This has two consequences: callers don't know what to handle; code reviewers can't judge reliability from the signature alone.
Four Error-Handling Approaches Compared
| Approach | Example | Pros | Cons |
|---|---|---|---|
| Throw exceptions | JS tradition, Java | Simple to write | Errors invisible in signatures; callers can ignore them |
| Return null | Common JS idiom | Simple | Error reason lost; null can be used without checking |
Return [error, value] tuple |
Go style | Errors visible | Easy to forget to check first element; semantics unclear when both are null |
Return Result<T, E> |
Rust, Haskell | Full error type; compiler forces handling | Requires helper functions; learning curve |
// Approach 2: return null โ reason lost
async function findUser(id: string): Promise<User | null> {
// Network failure? Not found? Forbidden? All become null.
return db.users.findById(id);
}
// Approach 3: Go-style tuple โ easy to forget the check
async function findUser(id: string): Promise<[Error | null, User | null]> {
try {
const user = await db.users.findById(id);
return [null, user];
} catch (e) {
return [e as Error, null];
}
}
const [err, user] = await findUser("123");
user.name; // forgot to check err! runtime crash possible, no compiler warning
Implementing Result<T, E>
// Core type definitions
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;
// Constructor helpers
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E>(error: E): Err<E> {
return { ok: false, error };
}
// Type guards
function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.ok === true;
}
function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return result.ok === false;
}
Chaining: map, flatMap, mapError
Checking result.ok on every step gets verbose. Combinators make Result composition fluent.
// map: transform the success value, pass through errors unchanged
function map<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.ok) return ok(fn(result.value));
return result;
}
// flatMap: chain an operation that can itself fail (monadic bind)
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.ok) return fn(result.value);
return result;
}
// mapError: transform the error type, pass through success unchanged
function mapError<T, E, F>(
result: Result<T, E>,
fn: (error: E) => F
): Result<T, F> {
if (!result.ok) return err(fn(result.error));
return result;
}
// Example: two operations with different error types
type ParseError = { code: "PARSE_ERROR"; raw: string };
type NetworkError = { code: "NETWORK_ERROR"; status: number };
function parseId(raw: string): Result<number, ParseError> {
const n = parseInt(raw, 10);
if (isNaN(n)) return err({ code: "PARSE_ERROR", raw });
return ok(n);
}
async function fetchUserById(id: number): Promise<Result<User, NetworkError>> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return err({ code: "NETWORK_ERROR", status: res.status });
return ok(await res.json());
}
// Chain: each error type is distinct and preserved
async function getUser(rawId: string): Promise<Result<User, ParseError | NetworkError>> {
const idResult = parseId(rawId);
if (!idResult.ok) return idResult;
return fetchUserById(idResult.value);
}
Real Example: User Registration Flow
Registration has multiple independent failure points: email format, password strength, username availability, database write.
// Define all possible error types upfront
type RegistrationError =
| { code: "INVALID_EMAIL"; email: string }
| { code: "WEAK_PASSWORD"; reason: string }
| { code: "USERNAME_TAKEN"; username: string }
| { code: "DATABASE_ERROR"; message: string };
interface RegistrationInput {
email: string;
password: string;
username: string;
}
interface User {
id: string;
email: string;
username: string;
}
// Each validation step returns Result
function validateEmail(email: string): Result<string, RegistrationError> {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return err({ code: "INVALID_EMAIL", email });
}
return ok(email.toLowerCase());
}
function validatePassword(password: string): Result<string, RegistrationError> {
if (password.length < 8)
return err({ code: "WEAK_PASSWORD", reason: "Password must be at least 8 characters" });
if (!/[A-Z]/.test(password))
return err({ code: "WEAK_PASSWORD", reason: "Password must contain an uppercase letter" });
if (!/[0-9]/.test(password))
return err({ code: "WEAK_PASSWORD", reason: "Password must contain a digit" });
return ok(password);
}
async function checkUsernameAvailable(
username: string
): Promise<Result<string, RegistrationError>> {
const existing = await db.users.findByUsername(username);
if (existing) return err({ code: "USERNAME_TAKEN", username });
return ok(username);
}
async function createUserRecord(
email: string,
password: string,
username: string
): Promise<Result<User, RegistrationError>> {
try {
const user = await db.users.create({
email,
password: await hash(password),
username,
});
return ok(user);
} catch (e) {
return err({ code: "DATABASE_ERROR", message: (e as Error).message });
}
}
// Full registration flow โ error types fully visible in the signature
async function registerUser(
input: RegistrationInput
): Promise<Result<User, RegistrationError>> {
const emailResult = validateEmail(input.email);
if (!emailResult.ok) return emailResult;
const passwordResult = validatePassword(input.password);
if (!passwordResult.ok) return passwordResult;
const usernameResult = await checkUsernameAvailable(input.username);
if (!usernameResult.ok) return usernameResult;
return createUserRecord(emailResult.value, input.password, usernameResult.value);
}
// Caller: every failure mode is known and handled precisely
async function handleRegistration(input: RegistrationInput) {
const result = await registerUser(input);
if (!result.ok) {
switch (result.error.code) {
case "INVALID_EMAIL":
showFieldError("email", `Invalid email: ${result.error.email}`);
break;
case "WEAK_PASSWORD":
showFieldError("password", result.error.reason);
break;
case "USERNAME_TAKEN":
showFieldError("username", `"${result.error.username}" is already taken`);
break;
case "DATABASE_ERROR":
showGlobalError("Registration failed. Please try again.");
console.error(result.error.message);
break;
}
return;
}
// result.value is User โ fully type-safe
redirectToDashboard(result.value.id);
}
Converting try/catch to Result
For APIs you don't control (JSON.parse, third-party libraries), wrap them once.
// Generic try/catch wrapper
function tryCatch<T>(fn: () => T): Result<T, Error> {
try {
return ok(fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
async function tryCatchAsync<T>(
fn: () => Promise<T>
): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
// Usage
const parsed = tryCatch(() => JSON.parse(rawString));
if (!parsed.ok) {
console.error("JSON parse failed:", parsed.error.message);
return;
}
const data = parsed.value; // type-safe
// Wrap fetch
async function safeFetch(url: string): Promise<Result<Response, Error>> {
return tryCatchAsync(() => fetch(url));
}
// Chain safeFetch with JSON parsing
async function fetchJson<T>(url: string): Promise<Result<T, Error>> {
const fetchResult = await safeFetch(url);
if (!fetchResult.ok) return fetchResult;
return tryCatchAsync(() => fetchResult.value.json() as Promise<T>);
}
When to Still Use throw
Result is not universal. These situations warrant throwing:
// 1. Programming errors (invariant violations): states that should never occur
function getElement(index: number, arr: readonly unknown[]): unknown {
if (index < 0 || index >= arr.length) {
// This is a caller bug, not an expected runtime failure
throw new RangeError(
`Index ${index} out of bounds for array of length ${arr.length}`
);
}
return arr[index];
}
// 2. Constructors: they cannot return Result
class Config {
constructor(raw: unknown) {
if (!isValidConfig(raw)) {
throw new Error("Invalid config"); // no alternative
}
}
}
// 3. Truly unexpected errors (out of memory, process crash)
// These cannot be meaningfully handled in business logic โ let them bubble up
// Rule of thumb:
// Result โ expected failure paths in business logic
// throw โ programming errors, constructors, unrecoverable system errors
Antipattern: Result Inside Implementation Details
// Antipattern: private methods returning Result โ unnecessarily verbose
class UserParser {
private parseField(raw: unknown): Result<string, Error> {
if (typeof raw !== "string") return err(new Error("not a string"));
return ok(raw); // the only caller is this class โ throw would be simpler
}
parse(data: unknown): Result<User, Error> {
const nameResult = this.parseField((data as any).name);
if (!nameResult.ok) return nameResult;
// ...
}
}
// Fix: throw internally, convert to Result at the public boundary
class UserParser {
private parseField(raw: unknown): string {
if (typeof raw !== "string") throw new Error("not a string");
return raw; // clean and simple
}
parse(data: unknown): Result<User, Error> {
return tryCatch(() => ({
// parseField throws are caught by tryCatch and become Err
name: this.parseField((data as any).name),
email: this.parseField((data as any).email),
}));
}
}
Summary
| Approach | Error Visibility | Forced Handling | Chainable | Use When |
|---|---|---|---|---|
throw |
Hidden in signature | No | No | Programming errors, constructors |
T | null |
Visible but reason lost | No | Poor | Simple "not found" |
[E, T] tuple |
Visible | No | Poor | Not recommended |
Result<T, E> |
Full typed error | Yes | Excellent | Expected business failure paths |
What's Next
Chapter 15 covers async/Promise types: how Promise.all infers tuple types, the Awaited<T> utility type, and the async error typing problem โ combined with the Result<T, E> pattern from this chapter to build a complete async type-safety system.