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
# 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()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)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")Merge registries into a larger union:
const AppFault = Faultier.merge(AuthFault, BillingFault)Conflicting duplicate tags (same tag, different constructor) throw RegistryMergeConflictError.
Use matchTag when you only need to handle one specific fault type:
const result = AuthFault.matchTag(
error,
"TimeoutError",
() => "retry",
() => "ignore"
)Use matchTags to handle several fault types:
const result = AuthFault.matchTags(error, {
NotFoundError: (fault) => ({ status: 404 }),
TimeoutError: (fault) => ({ status: 503 }),
})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).
| 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 |
|---|---|
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) |
isFault(value) |
Type guard for Fault instances (not cross-realm safe) |
fromSerializable(data) |
Reconstruct a generic Fault (no subclass restoration) |
Runtime: Fault, Tagged, registry, merge, isFault, fromSerializable, ReservedFieldError, RegistryTagMismatchError, RegistryMergeConflictError
Types: FaultRegistry, FlattenOptions, FlattenField, SerializableFault, SerializableCause
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
TaggedthrowReservedFieldError.
- 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
