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 logsTable of Contents
- Tagged subclasses β Define fault types as real classes with
_tagdiscriminants - Typed context β Associate structured fields with each fault type
- Dual messages β Separate
detailsfor logs from user-facingmessage - Error chaining β Wrap and re-throw errors while preserving the full cause chain
- Registries β Group fault types into scoped unions with
create,wrap, andmatchAPIs - Serializable β Convert faults to wire format and reconstruct them
- Instanceof support β Use
instanceofchecks with your fault subclasses - No dependencies β Zero runtime dependencies
Faultier requires TypeScript 5.4 or newer.
# bun
bun add faultier
# npm
npm install faultier
# yarn
yarn add faultier
# pnpm
pnpm add faultier| 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.
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
}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)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 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.TypeMerge registries into a larger union:
const AppFault = Faultier.merge(AuthFault, BillingFault)Conflicting duplicate tags (same tag, different constructor) throw
RegistryMergeConflictError, available from faultier/errors.
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"
)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.
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.
| 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 |
| 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 |
| 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) |
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"function toHttpStatus(err: unknown) {
return AppFault.matchTags(
err,
{
NotFoundError: () => 404,
ValidationError: () => 422,
DatabaseError: () => 503,
},
() => 500
)
}try {
await riskyOperation()
} catch (err) {
// Wraps anything β Error instances, strings, even undefined
throw AppFault.wrap(err).as("DatabaseError")
}// 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)- Cause chains are capped at 100 levels (
MAX_CAUSE_DEPTH) in traversal, serialization, and deserialization to prevent stack overflow. - Reserved constructor field names in
TaggedthrowReservedFieldErrorfromfaultier/errors. This covers wire envelope keys, Fault methods, and inherited prototype members (constructor,toString,__proto__, ...) β see Serialization for the full rule.
- 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
_tagdiscriminants (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).
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
This project is inspired by the Fault library.
Made with π₯ pastry
