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/elsechains - 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 (4)
This is a much-needed revolution in TypeScript error handling. Exhaustive matching combined with zero dependencies offers a compelling way to tackle verbosity and forgotten cases, elevating both DX and reliability.
Thank you
I also felt that the TypeScript ecosystem was lacking regarding this topic
Feel free to test ts-typed-errors and provide feedback
Great work addressing this gap !! Curious, have you considered integrating ts typed errors with React or Next.js for error boundaries ? It feels like a natural fit. Looking forward to seeing how this evolves !!
Thank you! Great suggestion 🙏
React/Next.js error boundaries are actually already planned in the roadmap. It's definitely a natural fit, especially for type-safe error recovery and cleaner component-level error handling.
I'd love to hear your thoughts on what would make it most useful for your projects! Feel free to open an issue on GitHub with your ideas or specific use cases. Community feedback will help prioritize and shape this feature 🚀