Skip to content

ackermannQ/ts-typed-errors

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

14 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

ts-typed-errors

npm version bundle size CI

πŸ›‘οΈ 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 }

✨ Features

  • 🎯 Exhaustive matching - TypeScript enforces that you handle all error types
  • πŸ”§ Ergonomic API - Declarative matchError / matchErrorOf chains 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() and matchErrorOfAsync()
  • πŸ’Ύ Serialization - JSON serialization with serialize(), deserialize(), toJSON(), fromJSON()

πŸš€ Quick Start

Installation

npm install ts-typed-errors

Basic Usage

import { 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); }

πŸ“š What is Exhaustive Error Matching?

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 }

πŸ”§ API Reference

Core Functions

defineError(name)<Data>()

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' }

wrap(fn)

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 }

matchError(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}`);

matchErrorOf<AllErrors>(error)

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 missing

matchErrorAsync(error) & matchErrorOfAsync<AllErrors>(error)

Async 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 handled

Advanced Matching

.map(transform)

Transform 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

.select(constructor, key, handler)

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

.withAny(constructors, handler)

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

.withNot(constructor | constructors, handler)

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

Utility Functions

isError(value)

Type guard to check if a value is an Error instance.

if (isError(value)) { // value is Error }

hasCode(code)

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

isErrorOf(constructor, predicate?)

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

isAnyOf(error, constructors)

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) { // ... }

isAllOf(value, guards)

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'); }

Serialization

serialize(error, includeStack?)

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

deserialize(serialized, constructors)

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

toJSON(error) & fromJSON(json, constructors)

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

🎯 Advanced Examples

Custom Error Hierarchy

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

Result Chaining

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; };

πŸ—οΈ Architecture

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

πŸ“– Documentation

πŸ“„ License

MIT Β© Quentin Ackermann

About

πŸ›‘οΈ Exhaustive error matching for TypeScript - tiny, dependency-free, type-safe.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •