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
- GitHub: https://github.com/ackermannQ/ts-typed-errors
- npm: https://www.npmjs.com/package/ts-typed-errors
- Original Article: https://dev.to/ackermannq/why-i-built-ts-typed-errors-a-typescript-error-handling-revolution-2bph
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:
- Listen to users - Pattern composition came from community feedback
- Iterate quickly - 5 phases in a few months
- Stay focused - Every feature is error-specific
- Performance matters - O(1) matching was crucial
- 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?
Image credits to Milad Fakurian
Top comments (0)