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

October 19, 2025

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()

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,
])

Async Support

await matchErrorAsync(error)
  .with(NetworkError, async e => {
    await logToService(e)
    return "logged"
  })
  .otherwise(async () => "unknown")

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")

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)

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])

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"

P.union() - Match ANY Pattern

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

matchError(error)
  .with(isConnectionError, () => "Retry connection")
  .otherwise(() => "Other error")

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")

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")
}

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")

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")

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()
  })

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")

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")

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
}

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")
    })
}

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")
}

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)

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()

Framework Integrations

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

// tRPC error handling
export const createContext = createTRPCContext({
  errorFormatter: tsTypedErrorsFormatter,
})

Error Context Propagation

// Automatic context tracking
const { wrap } = withContext({ requestId: "123", userId: "abc" })
const result = await wrap(riskyOperation)()
// All errors include requestId and userId

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
}

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
}

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();

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(...);

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), ...)

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")

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")

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)

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
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"

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! 💬


Q. Ackermann – Senior Engineer, Toolmaker, Systems Thinker
GitHub | KodeReview | LinkedIn | X


Profile picture
Quentin Ackermann

Full-stack engineer crafting developer tools in React, Node.js, and Unity - with a taste for AI, clean architecture, and creative problem-solving.

© 2025 brewed with