
Error Handling in TypeScript That Actually Scales
The thing that bothers me about try/catch in TypeScript is that it's invisible in the type system. A function that might throw looks identical to a function that can't. There's no way to know from a function's signature whether you need to wrap the call in a try/catch, and the compiler won't tell you if you forget.
This is fine in small codebases. In larger ones, it becomes a game of "which calls might throw and have I handled all of them?"
The approach I've settled on borrowed from Rust's Result type and popularized in TypeScript circles by libraries like neverthrow makes errors a return value instead of a thrown exception. The type signature tells you a function can fail. The compiler forces you to check.
The Result Type
typescripttype Ok<T> = { success: true; data: T } type Err<E> = { success: false; error: E } type Result<T, E = Error> = Ok<T> | Err<E> function ok<T>(data: T): Ok<T> { return { success: true, data } } function err<E>(error: E): Err<E> { return { success: false, error } }
A function that returns Result<User, DatabaseError> is telling you: "I either give you a User or I give you a DatabaseError. You must handle both."
Using It
typescriptasync function getUserById(id: string): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> { try { const user = await db.user.findUnique({ where: { id } }) if (!user) return err('NOT_FOUND') return ok(user) } catch { return err('DB_ERROR') } } // The caller is forced to handle both cases const result = await getUserById(userId) if (!result.success) { if (result.error === 'NOT_FOUND') { return res.status(404).json({ error: 'User not found' }) } return res.status(500).json({ error: 'Database error' }) } // result.data is now narrowed to User no casting const user = result.data
The key move: the error is a discriminated union ('NOT_FOUND' | 'DB_ERROR'), not a generic Error. The switch or if chain is now exhaustive TypeScript knows when you haven't handled a case.
Domain-Specific Error Types
String literals work for simple cases, but for anything with additional context, you want structured error types:
typescript// errors/index.ts export type AppError = | { code: 'NOT_FOUND'; resource: string; id: string } | { code: 'UNAUTHORIZED'; reason: string } | { code: 'VALIDATION_ERROR'; fields: Record<string, string[]> } | { code: 'RATE_LIMITED'; retryAfterSeconds: number } | { code: 'EXTERNAL_SERVICE_ERROR'; service: string; message: string } | { code: 'DATABASE_ERROR'; message: string } // Helper constructors export const AppErrors = { notFound: (resource: string, id: string): AppError => ({ code: 'NOT_FOUND', resource, id }), unauthorized: (reason: string): AppError => ({ code: 'UNAUTHORIZED', reason }), validationError: (fields: Record<string, string[]>): AppError => ({ code: 'VALIDATION_ERROR', fields }), rateLimited: (retryAfterSeconds: number): AppError => ({ code: 'RATE_LIMITED', retryAfterSeconds }), }
The constructors give you a typed, consistent way to create errors across the codebase, and the discriminated union means every switch (error.code) can be checked for exhaustiveness.
Composing Results
The pattern breaks down when you have a chain of fallible operations. Nesting if (!result.success) checks gets ugly fast:
typescript// Ugly async function getPostWithAuthor(postId: string) { const postResult = await getPost(postId) if (!postResult.success) return postResult const authorResult = await getUser(postResult.data.authorId) if (!authorResult.success) return authorResult const permissionsResult = await checkPermissions(authorResult.data.id) if (!permissionsResult.success) return permissionsResult return ok({ post: postResult.data, author: authorResult.data }) }
You can clean this up with a few combinators:
typescript// Chain: run fn only if result is Ok, pass through errors function chain<T, E, U>( result: Result<T, E>, fn: (data: T) => Result<U, E> ): Result<U, E> { if (!result.success) return result return fn(result.data) } // Async version async function chainAsync<T, E, U>( result: Result<T, E>, fn: (data: T) => Promise<Result<U, E>> ): Promise<Result<U, E>> { if (!result.success) return result return fn(result.data) } // Map: transform Ok value, pass through errors function mapResult<T, E, U>( result: Result<T, E>, fn: (data: T) => U ): Result<U, E> { if (!result.success) return result return ok(fn(result.data)) }
Now the chain is readable:
typescriptasync function getPostWithAuthor(postId: string) { const postResult = await getPost(postId) const authorResult = await chainAsync(postResult, (post) => getUser(post.authorId)) const permissionsResult = await chainAsync(authorResult, (author) => checkPermissions(author.id) ) if (!permissionsResult.success) return permissionsResult return ok({ post: (postResult as Ok<Post>).data, author: (authorResult as Ok<User>).data, }) }
Still verbose, but the error propagation is explicit and the types are tracking it.
Wrapping Third-Party Code
Most external libraries throw. You'll want wrapper functions that convert thrown errors to Result:
typescriptasync function tryCatch<T>( fn: () => Promise<T> ): Promise<Result<T, Error>> { try { return ok(await fn()) } catch (err) { return err instanceof Error ? err : new Error(String(err)) // wait, this is wrong let's be explicit } } // Better version with error mapping async function tryCatch<T, E>( fn: () => Promise<T>, mapError: (err: unknown) => E ): Promise<Result<T, E>> { try { return ok(await fn()) } catch (error) { return { success: false, error: mapError(error) } } } // Usage const fileResult = await tryCatch( () => fs.promises.readFile(path, 'utf-8'), (err) => ({ code: 'FILE_READ_ERROR' as const, path, message: err instanceof Error ? err.message : 'Unknown error', }) )
Converting Results to HTTP Responses
At the API boundary, you translate domain errors to HTTP responses. One place, one function:
typescript// middleware/result-handler.ts import { Response } from 'express' import { AppError } from '../errors' export function sendResult<T>( res: Response, result: Result<T, AppError> ): void { if (result.success) { res.json(result.data) return } const error = result.error switch (error.code) { case 'NOT_FOUND': res.status(404).json({ error: `${error.resource} not found`, id: error.id, }) break case 'UNAUTHORIZED': res.status(401).json({ error: error.reason }) break case 'VALIDATION_ERROR': res.status(422).json({ error: 'Validation failed', fields: error.fields }) break case 'RATE_LIMITED': res.status(429).json({ error: 'Rate limit exceeded', retryAfter: error.retryAfterSeconds, }) break case 'EXTERNAL_SERVICE_ERROR': case 'DATABASE_ERROR': res.status(500).json({ error: 'Internal server error' }) break default: { // TypeScript exhaustiveness check — if you add a new error type // and forget to handle it here, this line won't compile const _exhaustive: never = error res.status(500).json({ error: 'Internal server error' }) } } }
Handlers become minimal:
typescriptapp.get('/posts/:id', async (req, res) => { const result = await getPost(req.params.id) sendResult(res, result) })
When Not to Use This
Try/catch is still appropriate for unexpected errors things that should never happen in normal operation and that you want to surface as 500s. You don't need to wrap every possible runtime error in Result.
The pattern earns its complexity when the error cases are meaningful to the caller. If the caller will do different things based on the error type, make it a Result. If the caller would just log it and return a 500 regardless, let it throw.
The goal isn't to eliminate exceptions. It's to make the expected failure modes explicit and typed so the compiler catches the gaps.