DEV Community

Cover image for Why I Built ts-typed-errors: A TypeScript Error Handling Revolution
ackermannQ
ackermannQ

Posted on

Why I Built ts-typed-errors: A TypeScript Error Handling Revolution

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. TypeScript's discriminated unions for exhaustiveness
  2. Fluent API design for ergonomics
  3. Zero dependencies for adoption
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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!"
}
Enter fullscreen mode Exit fullscreen mode

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)