Skip to content
adelrodriguezPublic

About

πŸ¦₯ Structured, extensible, type-safe error handling for TypeScript

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Latest commit

Β 

History

105 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Faultier Banner

πŸ¦₯ faultier

Structured, extensible, type-safe error handling for TypeScript

npm version License: MIT

Create, classify, and extend errors with type-safe tags and structured context. Define your fault types as classes, group them in registries, and use them throughout your application with full TypeScript support for error classification and associated metadata.

import * as Faultier from "faultier"

class NotFoundError extends Faultier.Tagged("NotFoundError")<{ id: string }>() {}
const fault = new NotFoundError({ id: "123" }).withDescription(
  "User not found",
  "DB query returned 0 rows"
)
fault._tag // "NotFoundError"
fault.id // "123"
fault.message // "User not found"           β€” user-facing
fault.details // "DB query returned 0 rows" β€” for logs
Table of Contents

Features

  • Tagged subclasses β€” Define fault types as real classes with _tag discriminants
  • Typed context β€” Associate structured fields with each fault type
  • Dual messages β€” Separate details for logs from user-facing message
  • Error chaining β€” Wrap and re-throw errors while preserving the full cause chain
  • Registries β€” Group fault types into scoped unions with create, wrap, and match APIs
  • Serializable β€” Convert faults to wire format and reconstruct them
  • Instanceof support β€” Use instanceof checks with your fault subclasses
  • No dependencies β€” Zero runtime dependencies

Installation

Faultier requires TypeScript 5.4 or newer.

# bun
bun add faultier

# npm
npm install faultier

# yarn
yarn add faultier

# pnpm
pnpm add faultier

Core Concepts

Term Meaning
Fault Base error class. Every faultier error extends it.
Tag A string discriminant (_tag) on each fault, used for matching.
message User-facing description ("User not found").
details Internal/diagnostic info for logs ("DB query returned 0 rows").
meta Arbitrary structured metadata ({ traceId, requestId, ... }).
context The merged meta from every fault in a cause chain (head wins on key conflicts).
Registry A scoped group of fault classes with create, wrap, and match helpers.

Not sure if Faultier is a good fit for your project? See When not to use Faultier.

Quick Start

Define tagged fault classes and throw/catch them with full type safety:

import * as Faultier from "faultier"

class NotFoundError extends Faultier.Tagged("NotFoundError")<{ id: string }>() {}
class DatabaseError extends Faultier.Tagged("DatabaseError")() {}

const AppFault = Faultier.registry({ NotFoundError, DatabaseError })

try {
  throw AppFault.create("NotFoundError", { id: "123" }).withMessage("User not found")
} catch (err) {
  AppFault.matchTags(err, {
    NotFoundError: (fault) => console.log(fault.id), // "123" β€” fully typed
    DatabaseError: () => console.log("db failed"),
  })
}

In a real application, you'd use registries to create and wrap errors across your codebase:

async function getUser(id: string) {
  let row: { id: string; name: string } | undefined

  try {
    row = await db.query("SELECT * FROM users WHERE id = ?", [id])
  } catch (err) {
    throw AppFault.wrap(err).as("DatabaseError")
  }

  if (!row) {
    throw AppFault.create("NotFoundError", { id })
  }

  return row
}

Usage

Tagged Faults

Use Tagged(tag) to create strongly typed fault subclasses with _tag as the discriminant.

import * as Faultier from "faultier"

// With typed fields
class ValidationError extends Faultier.Tagged("ValidationError")<{
  field: string
}>() {}

const e = new ValidationError({ field: "email" })

// Without fields
class TimeoutError extends Faultier.Tagged("TimeoutError")() {}

const t = new TimeoutError()

Tagged("Tag")<Fields>() uses two calls because TypeScript cannot partially infer generic arguments: the first call infers the tag literal, and the second accepts an optional field type. The empty second call creates a fault without custom fields.

All tagged faults extend Fault and support fluent setters:

const fault = new ValidationError({ field: "email" })
  .withDescription("Invalid email format", "Validation failed for user signup")
  .withMeta({ traceId: "trace-123" })
  .withCause(originalError)

Error Chaining

Faults preserve the full error chain from head (latest) to leaf (root cause):

const root = new Error("db down")
const inner = new TimeoutError()
  .withDescription("Service unavailable", "Upstream timeout after 30s")
  .withCause(root)
const outer = new NotFoundError({ resource: "user", id: "123" })
  .withDescription("User not found", "Lookup failed after retries")
  .withCause(inner)

outer.unwrap() // [outer, inner, root] β€” full chain as array
outer.getTags() // ["NotFoundError", "TimeoutError"] β€” all tags in chain
outer.getContext() // merged metadata from all faults (head wins on conflicts)

The chain follows native Error.cause links too, so a root cause nested inside a plain Error is still reachable.

Use flatten() to convert a cause chain to a string:

outer.flatten()
// "User not found -> Service unavailable -> db down"

outer.flatten({ field: "details" })
// "Lookup failed after retries -> Upstream timeout after 30s"

outer.flatten({
  field: "details",
  separator: " | ",
  formatter: (v) => v.toUpperCase(),
})
// "LOOKUP FAILED AFTER RETRIES | UPSTREAM TIMEOUT AFTER 30S"

flatten() accepts an options object:

Option Type Default Description
field "message" | "details" "message" Which field to collect from the chain
separator string " -> " Join separator between values
formatter (value: string) => string trim Transform each value before joining

When field is "message" (default), non-Fault nodes in the chain are included (via Error.message or string coercion). Consecutive duplicate values are deduplicated. When field is "details", only Fault nodes with a defined details field are included.

Registries

Registries give you a scoped API for a union of fault types:

const AuthFault = Faultier.registry({ NotFoundError, TimeoutError })

// Create faults by tag
const fault = AuthFault.create("NotFoundError", { resource: "user", id: "123" })

// Wrap existing errors
const wrapped = AuthFault.wrap(new Error("connection reset")).as("TimeoutError")

// Union of every fault in the registry: NotFoundError | TimeoutError
type AuthError = typeof AuthFault.Type

Merge registries into a larger union:

const AppFault = Faultier.merge(AuthFault, BillingFault)

Conflicting duplicate tags (same tag, different constructor) throw RegistryMergeConflictError, available from faultier/errors.

Handling Faults

Single Tag Matching

Use the standalone matchTag when you already have a typed Fault union. Its fallback excludes the matched type:

const result = Faultier.matchTag(
  error,
  "TimeoutError",
  () => "retry",
  () => "ignore"
)

Use the registry method for unknown input. It invokes handlers only for instances of the registered constructor, even when another Fault uses the same tag:

const result = AuthFault.matchTag(
  error,
  "TimeoutError",
  () => "retry",
  () => "ignore"
)

Multiple Tag Matching

The same distinction applies to matchTags. Standalone matching operates on a typed union and recognizes exhaustive handler maps:

const result = Faultier.matchTags(error, {
  NotFoundError: (fault) => ({ status: 404 }),
  TimeoutError: (fault) => ({ status: 503 }),
})

Registry matching accepts unknown, checks registry membership first, and therefore keeps undefined in the return type when no fallback is provided. Registry fallbacks receive the original value as unknown.

Serialization

Fault instances serialize to a plain object with __faultier: true:

const json = outer.toSerializable()

// Generic reconstruction (no subclass restoration)
const generic = Faultier.fromSerializable(json)

// Registry reconstruction (restores registered subclasses)
const restored = AuthFault.fromSerializable(json)

registry.toSerializable(err) supports Fault instances, native Error, and non-Error thrown values (serialized as UnknownThrown).

Registry reconstruction calls new Class(payloadFields), so a registered class's constructor must take its fields object as its only parameter. registry() rejects other signatures at compile time. A constructor with that signature can still derive a default message or normalize fields, and both run again on revive. For call-site sugar, add a static factory instead of changing the constructor:

class UserNotFound extends Faultier.Tagged("UserNotFound")<{ id: string }>() {
  constructor(fields: { id: string }) {
    super(fields)
    this.message = `User ${fields.id} not found`
  }

  static forId(id: string) {
    return new UserNotFound({ id })
  }
}

The check covers the signature only. On revive an instance holds exactly what its constructor builds from the payload fields, so state the constructor does not receive is not restored: fields stored under different names, fields a parameterless constructor fills in itself, and class fields initialised outside the constructor argument. Keep constructor input and stored fields the same shape.

The wire format uses the JSON-safe SerializableValue type. withMeta and Tagged payload fields are type-constrained at construction; thrown causes are normalized to JSON-safe values during serialization. Meta and payload values are not deep runtime validated: their serializability is a documented type contract. undefined values are preserved in the wire object itself; JSON transports drop undefined object properties and convert undefined array elements to null, while structured-clone-style transports preserve them.

A single reserved-key rule applies everywhere: a key is reserved when it is a wire envelope key (__faultier, _tag, name, message, details, meta, stack, cause) or would shadow anything on Fault's prototype chain β€” Fault methods (withMeta, withCause, unwrap, flatten, ...) and inherited built-ins (constructor, toString, toLocaleString, valueOf, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, __proto__, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__). Tagged constructors reject reserved field names with ReservedFieldError; deserialization preserves colliding wire keys with a __payload_ prefix instead (repeated when needed to avoid overwriting an existing payload field), since wire data from other sources must not be dropped.

API Reference

Fault Instance

Method Description
message User-facing message ("what happened")
details Technical/diagnostic context for developers and logs
withMessage(message) Set user-facing message (fluent)
withDetails(details) Set technical details (fluent)
withDescription(message, details?) Set both message and details (fluent)
withMeta(meta) Set structured metadata, merges with existing (fluent)
withCause(cause) Chain a cause (fluent)
unwrap() Cause chain as array [head, ..., leaf]
getTags() _tag values from all Faults in the chain
getContext() Merged metadata from all Faults (head wins on conflicts)
flatten(options?) Cause chain to string (see Error Chaining)
toSerializable() Serialize to wire format

Registry

Method Description
tags Registered tags in deterministic order
typeof registry.Type Union of the registry's fault types (type-only)
create(tag, fields?) Create a fault by tag
wrap(error).as(tag, fields?) Wrap an existing error as a tagged fault
is(error) Type guard for any fault in the registry
matchTag(error, tag, handler, fallback?) Single tag matching
matchTags(error, handlers, fallback?) Multiple tag matching
toSerializable(error) Serialize any error (Fault, Error, or unknown thrown value)
fromSerializable(data) Reconstruct a fault, restoring registered subclasses

Top-level (Faultier.*)

Method Description
Tagged(tag)<Fields>() Create a tagged Fault subclass with _tag as discriminant
registry({ ...ctors }) Create a scoped fault registry from tagged constructors
merge(a, b, ...rest) Merge registries into one union (throws on conflicting tags)
matchTag(...) Match one tag in a typed Fault union
matchTags(...) Match several tags in a typed Fault union
isFault(value) Type guard for Fault instances (not cross-realm safe)
fromSerializable(data) Reconstruct a generic Fault (no subclass restoration)

Exports

faultier: Fault, Tagged, registry, merge, matchTag, matchTags, isFault, fromSerializable

faultier/errors: ReservedFieldError, RegistryTagMismatchError, RegistryMergeConflictError

faultier/types: FaultRegistry, FlattenOptions, FlattenField, TagOf, ByTag, SerializableValue, SerializableFault, SerializableCause

import { RegistryMergeConflictError } from "faultier/errors"
import type { FaultRegistry, SerializableFault } from "faultier/types"

Common Recipes

Map faults to HTTP status codes

function toHttpStatus(err: unknown) {
  return AppFault.matchTags(
    err,
    {
      NotFoundError: () => 404,
      ValidationError: () => 422,
      DatabaseError: () => 503,
    },
    () => 500
  )
}

Wrap unknown errors safely

try {
  await riskyOperation()
} catch (err) {
  // Wraps anything β€” Error instances, strings, even undefined
  throw AppFault.wrap(err).as("DatabaseError")
}

Serialize across a boundary

// Server: serialize any error for the wire
const payload = AppFault.toSerializable(err)
res.json(payload)

// Client: reconstruct with subclass restoration
const fault = AppFault.fromSerializable(payload)
fault instanceof NotFoundError // true (if registered)

Notes

  • Cause chains are capped at 100 levels (MAX_CAUSE_DEPTH) in traversal, serialization, and deserialization to prevent stack overflow.
  • Reserved constructor field names in Tagged throw ReservedFieldError from faultier/errors. This covers wire envelope keys, Fault methods, and inherited prototype members (constructor, toString, __proto__, ...) β€” see Serialization for the full rule.

When not to use Faultier

  • Small scripts or one-off CLIs β€” plain throw new Error() is fine when you don't need classification.
  • You already have a tagged error solution β€” if your codebase already uses a library with _tag discriminants (e.g., Effect errors), adding Faultier would be redundant.
  • You don't want to maintain an error taxonomy β€” Faultier works best when your team commits to defining and evolving a set of fault classes. If that feels like too much overhead, it probably is.
  • Very high-volume failure paths β€” class instantiation per error is negligible for normal use, but may matter if errors are part of expected control flow at high frequency (e.g., validation in a tight loop).

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

Acknowledgments

This project is inspired by the Fault library.

Made with πŸ₯ pastry

License

MIT

About

πŸ¦₯ Structured, extensible, type-safe error handling for TypeScript

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

Generated from adelrodriguez/pastry