π‘οΈ Exhaustive error matching for TypeScript - tiny, dependency-free, type-safe.
import { defineError, matchErrorOf, wrap } from 'ts-typed-errors'; const NetworkError = defineError('NetworkError')<{ status: number; url: string }>(); const ParseError = defineError('ParseError')<{ at: string }>(); type Err = InstanceType<typeof NetworkError> | InstanceType<typeof ParseError>; const safeJson = wrap(async (url: string) => { const r = await fetch(url); if (!r.ok) throw new NetworkError(`HTTP ${r.status}`, { status: r.status, url }); try { return await r.json(); } catch { throw new ParseError('Invalid JSON', { at: url }); } }); const res = await safeJson('https://httpstat.us/404'); if (!res.ok) { return matchErrorOf<Err>(res.error) .with(NetworkError, e => `retry ${e.data.url}`) .with(ParseError, e => `report ${e.data.at}`) .exhaustive(); // β
TypeScript ensures all cases are covered }- π― Exhaustive matching - TypeScript enforces that you handle all error types
- π§ Ergonomic API - Declarative
matchError/matchErrorOfchains with:.map()for error transformation.select()for property extraction.withAny()for matching multiple types.withNot()for negation patterns.when()for predicate matching
- π¦ Tiny & fast - ~5 kB, zero dependencies, O(1) tag-based matching
- π‘οΈ Type-safe - Full TypeScript support with strict type checking
- π Result pattern - Convert throwing functions to
Result<T, E>types - π¨ Composable guards - Reusable type guards with
isErrorOf(),isAnyOf(),isAllOf() - β‘ Async support - Native async/await with
matchErrorAsync()andmatchErrorOfAsync() - πΎ Serialization - JSON serialization with
serialize(),deserialize(),toJSON(),fromJSON()
npm install ts-typed-errorsimport { defineError, matchErrorOf, wrap } from 'ts-typed-errors'; // 1. Define your error types const NetworkError = defineError('NetworkError')<{ status: number; url: string }>(); const ValidationError = defineError('ValidationError')<{ field: string; value: any }>(); type AppError = InstanceType<typeof NetworkError> | InstanceType<typeof ValidationError>; // 2. Wrap throwing functions const safeFetch = wrap(async (url: string) => { const response = await fetch(url); if (!response.ok) { throw new NetworkError(`HTTP ${response.status}`, { status: response.status, url }); } return response.json(); }); // 3. Handle errors exhaustively const result = await safeFetch('https://api.example.com/data'); if (!result.ok) { const message = 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}`) .exhaustive(); // β
Compiler ensures all cases covered console.log(message); }Since TypeScript 4.4, every catch block receives an unknown type. This means you need to manually narrow error types with verbose if/else blocks:
// β Verbose and error-prone try { await riskyOperation(); } catch (error) { if (error instanceof NetworkError) { // handle network error } else if (error instanceof ValidationError) { // handle validation error } else { // handle unknown error } }ts-typed-errors makes this ergonomic and type-safe:
// β
Clean and exhaustive const result = await wrap(riskyOperation)(); if (!result.ok) { return matchErrorOf<AllErrors>(result.error) .with(NetworkError, handleNetwork) .with(ValidationError, handleValidation) .exhaustive(); // Compiler ensures you handle all cases }Creates a typed error class with optional data payload.
const UserError = defineError('UserError')<{ userId: string; reason: string }>(); const error = new UserError('User not found', { userId: '123', reason: 'deleted' }); // error.tag === 'UserError' // error.data === { userId: '123', reason: 'deleted' }Converts a throwing function to return Result<T, E>.
const safeJson = wrap(async (url: string) => { const response = await fetch(url); if (!response.ok) throw new Error('HTTP error'); return response.json(); }); const result = await safeJson('https://api.example.com'); if (result.ok) { console.log(result.value); // T } else { console.log(result.error); // Error }Free matcher for any error type. Always requires .otherwise().
const message = matchError(error) .with(NetworkError, e => `Network: ${e.data.status}`) .with(ValidationError, e => `Validation: ${e.data.field}`) .otherwise(e => `Unknown: ${e.message}`);Exhaustive matcher that ensures all error types are handled.
type AllErrors = NetworkError | ValidationError | ParseError; const message = matchErrorOf<AllErrors>(error) .with(NetworkError, e => `Network: ${e.data.status}`) .with(ValidationError, e => `Validation: ${e.data.field}`) .with(ParseError, e => `Parse: ${e.data.at}`) .exhaustive(); // β
Compiler error if any case missingAsync versions with native async/await support for all handlers.
// Free-form async matching const result = await matchErrorAsync(error) .with(NetworkError, async (err) => { await logToService(err); return `Logged network error: ${err.data.status}`; }) .with(ParseError, async (err) => { await notifyAdmin(err); return `Notified admin about parse error`; }) .otherwise(async (err) => `Unknown error: ${err}`); // Exhaustive async matching const result = await matchErrorOfAsync<AllErrors>(error) .with(NetworkError, async (err) => { await retryRequest(err); return 'retried'; }) .with(ValidationError, async (err) => { await validateAndLog(err); return 'validation'; }) .with(ParseError, async (err) => { await fixData(err); return 'fixed'; }) .exhaustive(); // β
All cases handledTransform the error before matching against it. Useful for normalizing errors or adding context.
const NetworkError = defineError('NetworkError')<{ status: number; url: string }>(); const ParseError = defineError('ParseError')<{ at: string }>(); // Normalize errors by adding a timestamp matchErrorOf<Err>(error) .map(e => { (e as any).timestamp = Date.now(); return e; }) .with(NetworkError, e => `Network error at ${(e as any).timestamp}`) .with(ParseError, e => `Parse error at ${(e as any).timestamp}`) .exhaustive(); // Extract nested errors matchError(wrappedError) .map(e => (e as any).cause ?? e) .with(NetworkError, e => `Root cause: ${e.data.status}`) .otherwise(() => 'Unknown error');Benefits:
- Error normalization across different sources
- Extract nested/wrapped errors
- Add contextual information
- Works with both exhaustive and non-exhaustive matching
Extract and match on specific properties from error data directly.
const NetworkError = defineError('NetworkError')<{ status: number; url: string }>(); const ParseError = defineError('ParseError')<{ at: string }>(); // Extract specific property instead of full error object matchErrorOf<Err>(error) .select(NetworkError, 'status', (status) => `Status code: ${status}`) .select(ParseError, 'at', (location) => `Parse failed at: ${location}`) .exhaustive(); // Mix with regular .with() handlers matchError(error) .select(NetworkError, 'status', (status) => status > 400 ? 'client error' : 'ok') .with(ParseError, (e) => `Parse error at ${e.data.at}`) .otherwise(() => 'unknown');Benefits:
- Cleaner handler signatures
- Direct access to needed properties
- Type-safe property extraction
- Works with exhaustive matching
Match multiple error types with the same handler.
const NetworkError = defineError('NetworkError')<{ status: number }>(); const TimeoutError = defineError('TimeoutError')<{ duration: number }>(); const ParseError = defineError('ParseError')<{ at: string }>(); matchErrorOf<Err>(error) .withAny([NetworkError, TimeoutError], (e) => 'Connection issue - retry') .with(ParseError, (e) => `Parse error at ${e.data.at}`) .exhaustive();Benefits:
- DRY principle - avoid duplicating handlers
- Group similar error types together
- Cleaner code for common error handling
Match all errors except the specified types.
// Exclude single type matchError(error) .withNot(NetworkError, (e) => 'Not a network error') .otherwise(() => 'Network error'); // Exclude multiple types matchError(error) .withNot([NetworkError, ParseError], (e) => 'Neither network nor parse error') .otherwise((e) => 'Fallback');Benefits:
- Handle "everything except X" scenarios
- Reduce boilerplate for common cases
- More expressive API
Type guard to check if a value is an Error instance.
if (isError(value)) { // value is Error }Creates a type guard for errors with a specific error code.
const isDNSError = hasCode('ENOTFOUND'); const isPermissionError = hasCode('EACCES'); if (isDNSError(error)) { // Handle DNS error - TypeScript knows error.code is 'ENOTFOUND' } // Use in pattern matching matchError(error) .with(hasCode('ENOTFOUND'), (err) => 'DNS lookup failed') .with(hasCode('EACCES'), (err) => 'Permission denied') .otherwise((err) => 'Other error');Creates reusable type guards for specific error types with optional predicates.
const NetworkError = defineError('NetworkError')<{ status: number; url: string }>(); // Simple type guard const isNetworkError = isErrorOf(NetworkError); if (isNetworkError(error)) { console.log(error.data.status); // TypeScript knows this is NetworkError } // Type guard with predicate const isServerError = isErrorOf(NetworkError, (e) => e.data.status >= 500); const isClientError = isErrorOf(NetworkError, (e) => e.data.status >= 400 && e.data.status < 500); if (isServerError(error)) { console.log(`Server error: ${error.data.status}`); } // Use in pattern matching matchError(error) .with(isServerError, (e) => 'Retry server error') .with(isClientError, (e) => 'Handle client error') .otherwise(() => 'Other error');Checks if an error is an instance of any of the provided error constructors.
const NetworkError = defineError('NetworkError')<{ status: number }>(); const TimeoutError = defineError('TimeoutError')<{ duration: number }>(); if (isAnyOf(error, [NetworkError, TimeoutError])) { // Handle connection-related errors console.log('Connection issue detected'); } // More concise than: if (error instanceof NetworkError || error instanceof TimeoutError) { // ... }Checks if a value matches all of the provided type guards.
const NetworkError = defineError('NetworkError')<{ status: number; url: string }>(); const isServerError = isErrorOf(NetworkError, (e) => e.data.status >= 500); const hasRetryableStatus = (e: unknown): e is any => isError(e) && 'status' in e && [502, 503, 504].includes((e as any).status); if (isAllOf(error, [isServerError, hasRetryableStatus])) { // Error is both a server error AND has a retryable status console.log('Retrying server error'); }Serializes an error to a JSON-safe object for transmission or storage.
const error = new NetworkError('Request failed', { status: 500, url: '/api' }); const serialized = serialize(error); // { // tag: 'NetworkError', // message: 'Request failed', // name: 'NetworkError', // data: { status: 500, url: '/api' }, // stack: '...' // } // Send over network await fetch('/api/log', { method: 'POST', body: JSON.stringify(serialized) });Deserializes a plain object back into an error instance.
// Receive from API const response = await fetch('/api/errors/123'); const serialized = await response.json(); // Deserialize with known constructors const error = deserialize(serialized, [NetworkError, ParseError]); if (error instanceof NetworkError) { console.log(`Network error: ${error.data.status}`); // Type-safe! }Convenience functions combining serialization with JSON stringify/parse.
// Convert to JSON string const json = toJSON(error); // Parse from JSON string const restored = fromJSON(json, [NetworkError, ParseError]);// Base error with common properties const BaseError = defineError('BaseError')<{ code: string }>(); // Specific errors extending base const DatabaseError = defineError('DatabaseError')<{ table: string; operation: string }>(); const AuthError = defineError('AuthError')<{ userId?: string; permission: string }>(); type AppError = InstanceType<typeof DatabaseError> | InstanceType<typeof AuthError>; // Exhaustive matching with data access const handleError = (error: AppError) => matchErrorOf<AppError>(error) .with(DatabaseError, e => ({ type: 'database', table: e.data.table, operation: e.data.operation, code: e.data.code })) .with(AuthError, e => ({ type: 'auth', userId: e.data.userId, permission: e.data.permission, code: e.data.code })) .exhaustive();const processUser = async (id: string) => { const userResult = await safeGetUser(id); if (!userResult.ok) return userResult; const validateResult = await safeValidateUser(userResult.value); if (!validateResult.ok) return validateResult; const saveResult = await safeSaveUser(validateResult.value); return saveResult; };ts-typed-errors is built around these core concepts:
- Typed Errors: Custom error classes with structured data
- Result Pattern: Functions return
Result<T, E>instead of throwing - Exhaustive Matching: Compiler-enforced error handling
- Zero Dependencies: Works in any TypeScript environment
MIT Β© Quentin Ackermann