How I solved the exhaustive error matching problem that every TypeScript developer faces
The Problem: TypeScript's unknown
Error Hell
Since TypeScript 4.4, every catch
block receives an unknown
type. This was a great improvement for type safety, but it created a new problem: verbose, error-prone error handling.
// ❌ The reality of modern TypeScript error handling
try {
await riskyOperation();
} catch (error) {
// error is unknown - we need to narrow it manually
if (error instanceof NetworkError) {
handleNetworkError(error);
} else if (error instanceof ValidationError) {
handleValidationError(error);
} else if (error instanceof DatabaseError) {
handleDatabaseError(error);
} else {
// What if we forget a case? No compile-time safety!
handleUnknownError(error);
}
}
This approach has several problems:
-
Verbose: Lots of repetitive
if/else
chains - Error-prone: Easy to forget error cases
- No exhaustiveness: TypeScript can't ensure all cases are handled
- Poor DX: No autocomplete or type safety in handlers
The Solution: Exhaustive Error Matching
I built ts-typed-errors
to solve this exact problem. Here's how it works:
// ✅ Clean, exhaustive, and type-safe
import { defineError, matchErrorOf, wrap } from 'ts-typed-errors';
// Define your error types
const NetworkError = defineError('NetworkError')<{ status: number; url: string }>();
const ValidationError = defineError('ValidationError')<{ field: string; value: any }>();
const DatabaseError = defineError('DatabaseError')<{ table: string; operation: string }>();
type AppError = InstanceType<typeof NetworkError> | InstanceType<typeof ValidationError> | InstanceType<typeof DatabaseError>;
// Wrap throwing functions
const safeOperation = wrap(async () => {
// Your risky operation here
});
// Handle errors exhaustively
const result = await safeOperation();
if (!result.ok) {
return matchErrorOf<AppError>(result.error)
.with(NetworkError, e => `Network error: ${e.data.status} for ${e.data.url}`)
.with(ValidationError, e => `Invalid ${e.data.field}: ${e.data.value}`)
.with(DatabaseError, e => `Database error in ${e.data.table} during ${e.data.operation}`)
.exhaustive(); // ✅ TypeScript ensures all cases are covered!
}
Why This Approach is Revolutionary
1. Compile-Time Exhaustiveness 🛡️
TypeScript enforces that you handle ALL possible error cases. If you add a new error type to your union, TypeScript will error until you handle it:
type AppError = NetworkError | ValidationError | DatabaseError | NewError; // Added NewError
const result = matchErrorOf<AppError>(error)
.with(NetworkError, handleNetwork)
.with(ValidationError, handleValidation)
.with(DatabaseError, handleDatabase)
// ❌ TypeScript error: NewError is not handled!
.exhaustive();
2. Zero Dependencies, Tiny Bundle 📦
- 1-2kb gzipped - smaller than most utility libraries
- Zero dependencies - works everywhere
- Tree-shakeable - only import what you use
npm install ts-typed-errors
# That's it! No peer dependencies, no lockfile bloat
3. Better Developer Experience 🚀
// Full autocomplete and type safety
matchErrorOf<AppError>(error)
.with(NetworkError, e => {
// e is fully typed as NetworkError
console.log(e.data.status); // ✅ TypeScript knows this exists
console.log(e.data.url); // ✅ TypeScript knows this exists
console.log(e.data.invalid); // ❌ TypeScript error: property doesn't exist
})
.exhaustive();
4. Works Everywhere 🌍
- Node.js - Server-side applications
- Browser - Frontend applications
- React/Next.js - Component error boundaries
- Express - API error handling
- Deno - Modern JavaScript runtime
Real-World Example: API Error Handling
Here's how I use ts-typed-errors
in a real API:
import { defineError, matchErrorOf, wrap } from 'ts-typed-errors';
// Define API-specific errors
const ValidationError = defineError('ValidationError')<{ field: string; value: any }>();
const NotFoundError = defineError('NotFoundError')<{ resource: string; id: string }>();
const RateLimitError = defineError('RateLimitError')<{ limit: number; remaining: number }>();
const DatabaseError = defineError('DatabaseError')<{ operation: string; table: string }>();
type APIError = InstanceType<typeof ValidationError> | InstanceType<typeof NotFoundError> |
InstanceType<typeof RateLimitError> | InstanceType<typeof DatabaseError>;
// Wrap your API functions
const safeGetUser = wrap(async (id: string) => {
if (!id) throw new ValidationError('ID required', { field: 'id', value: id });
const user = await db.users.findById(id);
if (!user) throw new NotFoundError('User not found', { resource: 'user', id });
return user;
});
// Handle errors in your route
app.get('/users/:id', async (req, res) => {
const result = await safeGetUser(req.params.id);
if (!result.ok) {
const errorResponse = matchErrorOf<APIError>(result.error)
.with(ValidationError, e => ({
status: 400,
message: `Invalid ${e.data.field}: ${e.data.value}`
}))
.with(NotFoundError, e => ({
status: 404,
message: `${e.data.resource} not found`
}))
.with(RateLimitError, e => ({
status: 429,
message: `Rate limit exceeded. ${e.data.remaining}/${e.data.limit} remaining`
}))
.with(DatabaseError, e => ({
status: 500,
message: `Database error during ${e.data.operation}`
}))
.exhaustive(); // ✅ All cases handled!
return res.status(errorResponse.status).json(errorResponse);
}
res.json(result.value);
});
The Journey: From Problem to Solution
The Inspiration
I was working on a large TypeScript codebase where error handling was becoming a nightmare. We had:
- 20+ different error types
- Inconsistent error handling across the codebase
- Silent failures due to forgotten error cases
- Verbose, unreadable error handling code
The Research
I looked at existing solutions:
- Result types: Great, but no exhaustive matching
- Either types: Functional, but complex for most developers
- Custom error classes: Better, but still verbose
- Pattern matching libraries: Close, but not designed for errors
The Breakthrough
The key insight was combining:
- TypeScript's discriminated unions for exhaustiveness
- Fluent API design for ergonomics
- Zero dependencies for adoption
- Result pattern for explicit error handling
The Implementation
// The core insight: use TypeScript's type system to track remaining cases
export type Next<Left, T> = Exclude<Left, HandlerInput<T>>;
export interface Matcher<Left> {
with<T>(ctorOrGuard: ErrorCtor<any> | Guard<any>, handler: (e: HandlerInput<T>) => any): Matcher<Next<Left, T>>;
exhaustive(this: Matcher<never>): any; // Only callable when Left is never
}
When you call .with()
, TypeScript removes the handled type from the union. When all types are handled, Left
becomes never
, and .exhaustive()
becomes callable.
The Impact
Since releasing ts-typed-errors
, I've seen:
- Cleaner error handling across entire codebases
- Fewer production bugs due to exhaustive error handling
- Better developer experience with full type safety
- Easier onboarding for new team members
Try It Yourself
npm install ts-typed-errors
import { defineError, matchErrorOf, wrap } from 'ts-typed-errors';
// Start with a simple example
const MyError = defineError('MyError')<{ code: string }>();
const safeFunction = wrap(() => { throw new MyError('Oops!', { code: 'E001' }); });
const result = await safeFunction();
if (!result.ok) {
const message = matchErrorOf<InstanceType<typeof MyError>>(result.error)
.with(MyError, e => `Error ${e.data.code}: ${e.message}`)
.exhaustive();
console.log(message); // "Error E001: Oops!"
}
The Future
I'm excited about the possibilities:
- Framework integrations (React error boundaries, Express middleware)
- Testing utilities (Jest/Vitest matchers)
- IDE extensions (VSCode snippets and autocomplete)
- Performance optimizations (even smaller bundle size)
Conclusion
ts-typed-errors
isn't just another utility library. It's a fundamental shift in how we think about error handling in TypeScript. By leveraging TypeScript's type system, we get:
- ✅ Compile-time safety - impossible to forget error cases
- ✅ Better ergonomics - clean, readable error handling
- ✅ Zero dependencies - works everywhere
- ✅ Tiny bundle - 1-2kb of pure TypeScript
The best part? It's not just for new projects. You can gradually adopt it in existing codebases, one function at a time.
Ready to revolutionize your error handling?
Get started with ts-typed-errors and let me know what you think!
What's your biggest pain point with error handling in TypeScript? Let me know in the comments!
Top comments (0)