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

  •  
  •