DEV Community

Cover image for ts-typed-errors v0.5.0: From Concept to Production-Ready Error Matching
ackermannQ
ackermannQ

Posted on

ts-typed-errors v0.5.0: From Concept to Production-Ready Error Matching

TL;DR: Since my initial article, ts-typed-errors has evolved from a simple error matching library to a full-featured pattern composition system. Here's what changed.


What Changed Since v0.1.0

When I first launched ts-typed-errors, it had basic exhaustive matching. The feedback was clear: developers wanted more power and composability.

So I went back to the drawing board and implemented 5 major phases of improvements.

📊 Before & After

Metric v0.1.0 (Initial) v0.5.0 (Now)
Bundle Size ~2 KB ~6.4 KB
Features 4 20+
Test Coverage 15 tests 92 tests
Pattern Builders 0 11
Phases Completed 1 5

Let's dive into what's new.


Phase 1-2: The Foundations (v0.1.0 - v0.2.0)

Property Selection with .select()

Extract properties directly in your handler:

const NetworkError = defineError('NetworkError')<{ status: number; url: string }>();

matchErrorOf<Err>(error)
  .select(NetworkError, 'status', (status) => {
    // Handler receives just the status number
    return `HTTP ${status}`;
  })
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

Why it matters: No more manual destructuring. Cleaner handlers.

Composition Utilities

// Check multiple types at once
if (isAnyOf(error, [NetworkError, TimeoutError])) {
  // Handle connection errors
}

// Combine type guards
const isServerError = isAllOf([
  isErrorOf(NetworkError),
  (e) => e.data.status >= 500
]);
Enter fullscreen mode Exit fullscreen mode

Async Support

await matchErrorAsync(error)
  .with(NetworkError, async (e) => {
    await logToService(e);
    return 'logged';
  })
  .otherwise(async () => 'unknown');
Enter fullscreen mode Exit fullscreen mode

Phase 3: Performance & Serialization (v0.3.0)

Error Transformation with .map()

Transform errors before matching:

matchError(error)
  .map(e => e.cause ?? e) // Extract root cause
  .with(NetworkError, e => `Network: ${e.data.status}`)
  .otherwise(() => 'Unknown');
Enter fullscreen mode Exit fullscreen mode

Use cases:

  • Normalize errors from different sources
  • Extract nested errors
  • Add contextual information

O(1) Tag-Based Matching

Under the hood, ts-typed-errors now uses a Map for instant lookups:

// Before: O(n) instanceof checks
for (const c of cases) if (c.test(e)) return c.run(e);

// After: O(1) tag lookup for defineError errors
const handler = tagHandlers.get(error.tag);
if (handler) return handler(error);
Enter fullscreen mode Exit fullscreen mode

Result: Faster matching for large error unions.

Error Serialization

Send errors over the wire safely:

// Serialize for API
const serialized = serialize(error);
// { tag: 'NetworkError', message: '...', data: {...}, stack: '...' }

// Send to client
res.json(serialized);

// Deserialize on client
const error = deserialize(json, [NetworkError, ParseError]);
Enter fullscreen mode Exit fullscreen mode

Phase 4: Pattern Composition (v0.4.0) 🔥

This is where it gets exciting. Inspired by ts-pattern, I built a full P namespace for pattern composition.

The P Namespace

import { P, matchError } from 'ts-typed-errors';
Enter fullscreen mode Exit fullscreen mode

P.union() - Match ANY Pattern

const isConnectionError = P.union(
  P.instanceOf(NetworkError),
  P.instanceOf(TimeoutError)
);

matchError(error)
  .with(isConnectionError, () => 'Retry connection')
  .otherwise(() => 'Other error');
Enter fullscreen mode Exit fullscreen mode

P.intersection() - Match ALL Patterns

// Match NetworkError with status >= 500
matchError(error)
  .with(
    P.intersection(
      P.instanceOf(NetworkError),
      P.when(e => e.data.status >= 500)
    ),
    e => `Server error: ${e.data.status}`
  )
  .otherwise(() => 'Other');
Enter fullscreen mode Exit fullscreen mode

Composable & Reusable Patterns

This is the real power:

// Define reusable patterns
const isServerError = P.intersection(
  P.instanceOf(NetworkError),
  P.when(e => e.data.status >= 500)
);

const isClientError = P.intersection(
  P.instanceOf(NetworkError),
  P.when(e => e.data.status >= 400 && e.data.status < 500)
);

const isCritical = P.union(
  isServerError,
  P.instanceOf(DatabaseError)
);

// Use patterns everywhere
function handleError(error: unknown) {
  return matchError(error)
    .with(isCritical, () => 'ALERT TEAM')
    .with(isClientError, () => 'Show user message')
    .otherwise(() => 'Log and continue');
}
Enter fullscreen mode Exit fullscreen mode

Available Pattern Builders

  • P.instanceOf(Constructor) - Match by constructor
  • P.when(predicate) - Match with predicate
  • P.guard(guardFn) - Use type guard functions
  • P.union(...patterns) - Match ANY pattern (OR logic)
  • P.intersection(...patterns) - Match ALL patterns (AND logic)
  • P.not(pattern) - Negate a pattern

Phase 5: Error-Specific Patterns (v0.5.0) 🚀

Now we have patterns specifically designed for error handling scenarios.

P.array() - Match Array Properties

Perfect for validation errors:

const ValidationError = defineError('ValidationError')<{
  errors: Array<{ field: string; message: string }>
}>();

matchError(error)
  .with(
    P.intersection(
      P.instanceOf(ValidationError),
      P.array('errors', P.when(e => e.field === 'email'))
    ),
    () => 'Email validation failed'
  )
  .otherwise(() => 'Other validation error');
Enter fullscreen mode Exit fullscreen mode

Use cases:

  • ValidationError with multiple field errors
  • AggregateError with error collections
  • Batch processing errors

P.hasCause() - Match Error Chains

Traverse the entire cause chain:

const rootCause = new NetworkError('failed', { status: 500, url: '/api' });
const middleError = Object.assign(new Error('middle'), { cause: rootCause });
const topError = Object.assign(new Error('top'), { cause: middleError });

matchError(topError)
  .with(P.hasCause(NetworkError), () => 'Network issue in chain')
  .otherwise(() => 'Other');
Enter fullscreen mode Exit fullscreen mode

Why it matters: Modern JavaScript supports error causes. Now you can match on them!

P.hasStack() - Match by Stack Trace

Categorize errors by where they came from:

matchError(error)
  .with(P.hasStack(/internal/), () => {
    // Internal application error
    alert('Our bad! We're on it.');
  })
  .with(P.hasStack(/node_modules/), () => {
    // Third-party library error
    logToSentry(error);
  })
  .otherwise(() => {
    // Application code error
    showUserFriendlyMessage();
  });
Enter fullscreen mode Exit fullscreen mode

Use cases:

  • Debugging and error categorization
  • Different handling for internal vs external errors
  • Stack-based error routing

P.optional() / P.nullish()

Match optional properties:

matchError(error)
  .with(P.nullish('cause'), () => 'No underlying cause')
  .with(P.optional('metadata'), () => 'Has optional metadata')
  .otherwise(() => 'Other');
Enter fullscreen mode Exit fullscreen mode

Complex Composition

Combine everything:

const ValidationError = defineError('ValidationError')<{
  errors: Array<{ field: string }>
}>();

const rootCause = new NetworkError('network', { status: 500, url: '/api' });
const error = Object.assign(
  new ValidationError('validation failed', {
    errors: [{ field: 'email' }]
  }),
  { cause: rootCause }
);

matchError(error)
  .with(
    P.intersection(
      P.instanceOf(ValidationError),
      P.array('errors', P.when(e => e.field === 'email')),
      P.hasCause(NetworkError)
    ),
    () => 'Email validation failed due to network issue'
  )
  .otherwise(() => 'Other');
Enter fullscreen mode Exit fullscreen mode

Real-World Use Cases

1. API Error Handling

async function fetchUser(id: string) {
  const result = await wrap(async () => {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new NetworkError('Request failed', {
        status: response.status,
        url: response.url
      });
    }
    return response.json();
  })();

  if (!result.ok) {
    return matchErrorOf<ApiError>(result.error)
      .with(
        P.intersection(
          P.instanceOf(NetworkError),
          P.when(e => e.data.status === 404)
        ),
        () => null
      )
      .with(
        P.intersection(
          P.instanceOf(NetworkError),
          P.when(e => e.data.status >= 500)
        ),
        () => {
          toast.error('Server error. Please try again.');
          return null;
        }
      )
      .with(P.instanceOf(NetworkError), (e) => {
        toast.error(`Request failed: ${e.data.status}`);
        return null;
      })
      .exhaustive();
  }

  return result.value;
}
Enter fullscreen mode Exit fullscreen mode

2. Form Validation

const ValidationError = defineError('ValidationError')<{
  errors: Array<{ field: string; message: string }>
}>();

function handleFormError(error: unknown) {
  return matchError(error)
    .with(
      P.array('errors', P.when(e => e.field === 'email')),
      (e) => {
        showFieldError('email', 'Invalid email address');
      }
    )
    .with(
      P.array('errors', P.when(e => e.field === 'password')),
      (e) => {
        showFieldError('password', 'Password too weak');
      }
    )
    .with(
      P.instanceOf(ValidationError),
      (e) => {
        showGeneralError('Please fix the highlighted fields');
      }
    )
    .otherwise(() => {
      showGeneralError('An error occurred');
    });
}
Enter fullscreen mode Exit fullscreen mode

3. Database Error Recovery

const DatabaseError = defineError('DatabaseError')<{
  code: string;
  query: string
}>();

async function queryWithRetry(query: string, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const result = await wrap(() => db.query(query))();

    if (result.ok) return result.value;

    const shouldRetry = matchError(result.error)
      .with(
        P.intersection(
          P.instanceOf(DatabaseError),
          P.when(e => e.data.code === 'CONNECTION_LOST')
        ),
        () => true
      )
      .with(
        P.intersection(
          P.instanceOf(DatabaseError),
          P.when(e => e.data.code === 'DEADLOCK')
        ),
        () => true
      )
      .otherwise(() => false);

    if (!shouldRetry) throw result.error;

    await sleep(Math.pow(2, i) * 1000); // Exponential backoff
  }

  throw new Error('Max retries exceeded');
}
Enter fullscreen mode Exit fullscreen mode

Bundle Size Impact

Despite adding 20+ features, the bundle stayed incredibly small:

Version Features Bundle Size Size per Feature
v0.1.0 4 2 KB 0.5 KB
v0.5.0 20+ 6.4 KB 0.32 KB

How?

  • Tree-shaking friendly
  • No external dependencies
  • Optimized for minification
  • Shared code between features

Performance Benchmarks

Tested with 1,000,000 error matches:

Tag-based matching (defineError):    ~15ms  (O(1) lookup)
instanceof matching:                 ~85ms  (O(n) iteration)
Pattern composition:                 ~95ms  (multiple tests)
Enter fullscreen mode Exit fullscreen mode

Key insight: For errors created with defineError(), matching is ~5.6x faster due to tag-based O(1) lookup.


What's Next: Phase 6 (v1.0.0)

The roadmap for v1.0.0 includes:

Error Recovery Patterns

// Built-in retry, fallback, circuit breaker
matchError(error)
  .with(NetworkError, retry(3, exponentialBackoff))
  .with(ValidationError, fallback(defaultValue))
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

Framework Integrations

// Express middleware
app.use(errorHandler([NetworkError, DatabaseError, ValidationError]));

// tRPC error handling
export const createContext = createTRPCContext({
  errorFormatter: tsTypedErrorsFormatter
});
Enter fullscreen mode Exit fullscreen mode

Error Context Propagation

// Automatic context tracking
const { wrap } = withContext({ requestId: '123', userId: 'abc' });
const result = await wrap(riskyOperation)();
// All errors include requestId and userId
Enter fullscreen mode Exit fullscreen mode

Comparison with Alternatives

vs try/catch

// try/catch: No type safety, easy to forget cases
try {
  await fetchData();
} catch (error) {
  // error is unknown
  // No compile-time guarantees
}

// ts-typed-errors: Full type safety
const result = await wrap(fetchData)();
if (!result.ok) {
  matchErrorOf<Err>(result.error)
    .with(NetworkError, ...)
    .with(ParseError, ...)
    .exhaustive(); // TypeScript enforces all cases
}
Enter fullscreen mode Exit fullscreen mode

vs neverthrow

// neverthrow: Great Result type, but manual matching
import { Result, ok, err } from 'neverthrow';

const result: Result<User, NetworkError | ParseError> = ...;
result.match(
  (user) => console.log(user),
  (error) => {
    // Manual instanceof checks
    if (error instanceof NetworkError) { ... }
    else if (error instanceof ParseError) { ... }
  }
);

// ts-typed-errors: Pattern matching + exhaustiveness
const result = await wrap(fetchUser)();
if (!result.ok) {
  matchErrorOf<Err>(result.error)
    .with(NetworkError, ...)
    .with(ParseError, ...)
    .exhaustive(); // Compile error if cases missing
}
Enter fullscreen mode Exit fullscreen mode

vs ts-pattern (for errors)

// ts-pattern: General-purpose pattern matching
import { match, P } from 'ts-pattern';

match(error)
  .with({ name: 'NetworkError' }, ...)
  .with({ name: 'ParseError' }, ...)
  .exhaustive();

// ts-typed-errors: Specialized for errors with type inference
matchErrorOf<Err>(error)
  .with(NetworkError, (e) => {
    // e is fully typed with data property
    e.data.status // ✅ Type-safe
  })
  .exhaustive();
Enter fullscreen mode Exit fullscreen mode

When to use what:

  • ts-pattern: General pattern matching (objects, arrays, primitives)
  • ts-typed-errors: Error-specific matching with error-focused features

Community Feedback & Iterations

Based on early adopter feedback, we made several changes:

1. "P namespace is verbose"

Solution: Keep both APIs - use P for composition, direct constructors for simple cases:

// Simple case: direct constructor
matchError(error)
  .with(NetworkError, ...)
  .otherwise(...);

// Complex case: P namespace
matchError(error)
  .with(P.intersection(P.instanceOf(NetworkError), P.when(...)), ...)
  .otherwise(...);
Enter fullscreen mode Exit fullscreen mode

2. "How do I match on error data?"

Solution: Added .select() and pattern composition:

// Extract specific property
.select(NetworkError, 'status', (status) => ...)

// Or match with P.when
.with(P.when(e => e instanceof NetworkError && e.data.status >= 500), ...)
Enter fullscreen mode Exit fullscreen mode

3. "Need better async support"

Solution: Added dedicated async matchers with proper Promise types:

await matchErrorAsync(error)
  .with(NetworkError, async (e) => {
    await logToService(e);
    return 'handled';
  })
  .otherwise(async () => 'unknown');
Enter fullscreen mode Exit fullscreen mode

Migration Guide

From v0.1.0 to v0.5.0

All existing code continues to work! We maintained backward compatibility.

But here's how to leverage new features:

// Before (v0.1.0)
matchError(error)
  .with(NetworkError, (e) => {
    if (e.data.status >= 500) {
      return 'Server error';
    }
    return 'Client error';
  })
  .otherwise(() => 'Unknown');

// After (v0.5.0) - more expressive
const isServerError = P.intersection(
  P.instanceOf(NetworkError),
  P.when(e => e.data.status >= 500)
);

const isClientError = P.intersection(
  P.instanceOf(NetworkError),
  P.not(P.when(e => e.data.status >= 500))
);

matchError(error)
  .with(isServerError, () => 'Server error')
  .with(isClientError, () => 'Client error')
  .otherwise(() => 'Unknown');
Enter fullscreen mode Exit fullscreen mode

Testing Strategy

All 92 tests pass with 100% coverage:

test/integration.test.ts (7 tests)test/index.test.ts (85 tests)

Test Files  2 passed (2)
Tests       92 passed (92)
Enter fullscreen mode Exit fullscreen mode

Test categories:

  • Basic matching (15 tests)
  • Pattern composition (16 tests)
  • Error-specific patterns (19 tests)
  • Async matching (8 tests)
  • Serialization (10 tests)
  • Edge cases (24 tests)

Installation & Quick Start

npm install ts-typed-errors
Enter fullscreen mode Exit fullscreen mode
import { defineError, matchError, P } from 'ts-typed-errors';

const NetworkError = defineError('NetworkError')<{
  status: number;
  url: string
}>();

const error = new NetworkError('Request failed', {
  status: 500,
  url: '/api/users'
});

const result = matchError(error)
  .with(
    P.intersection(
      P.instanceOf(NetworkError),
      P.when(e => e.data.status >= 500)
    ),
    (e) => `Server error: ${e.data.status}`
  )
  .otherwise(() => 'Unknown error');

console.log(result); // "Server error: 500"
Enter fullscreen mode Exit fullscreen mode

Links


Final Thoughts

From v0.1.0 to v0.5.0, ts-typed-errors evolved from "exhaustive error matching" to "composable error pattern matching system".

The journey taught me:

  1. Listen to users - Pattern composition came from community feedback
  2. Iterate quickly - 5 phases in a few months
  3. Stay focused - Every feature is error-specific
  4. Performance matters - O(1) matching was crucial
  5. DX is everything - Great docs and examples drive adoption

What would you add to Phase 6?

Drop your thoughts in the comments! 💬


Found this useful?

  • ⭐ Star on GitHub
  • 🐦 Follow me on Twitter
  • 📝 Share this article

Image credits to Milad Fakurian

Top comments (0)