Documentation
The complete type-safe error system for TypeScript. Start with throw/catch, adopt Result types when you're ready.
Installation
Install the core library and the optional ESLint plugin:
npm install faultline
npm install -D eslint-plugin-faultline
The ESLint plugin is recommended but not required. It catches common mistakes like throw new Error(...) and nudges you toward typed factories.
Define Your First Errors
Hand-rolled error classes are 15+ lines each, inconsistent, and drift out of sync.
import { defineErrors } from 'faultline';
const UserErrors = defineErrors('User', {
NotFound: {
status: 404,
message: (data: { userId: string }) => `User ${data.userId} not found`,
},
InvalidEmail: {
status: 400,
message: (data: { email: string; reason: string }) => `Invalid email: ${data.email}`,
},
Unauthorized: { status: 401 },
});
Tags and codes are auto-generated from the namespace and key. UserErrors.NotFound(...)._tag is 'User.NotFound', and .code is 'USER_NOT_FOUND'. Every error is a real Error instance with a full stack trace, structured data, and JSON serialization built in.
Enable ESLint
// eslint.config.js
import faultline from 'eslint-plugin-faultline';
export default [
faultline.configs.recommended,
];
Throwing Errors
Today you throw new Error('...') with no structure. The catch block gets unknown.
async function getUser(id: string): Promise {
const user = await db.users.findUnique({ where: { id } });
if (!user) throw UserErrors.NotFound({ userId: id });
return user;
}
The thrown error carries a tag ('User.NotFound'), a code ('USER_NOT_FOUND'), an HTTP status (404), typed data ({ userId: string }), and a full stack trace. It's a real Error instance — works with instanceof, console.error, and every tool that expects errors.
Catching with isErrorTag
In a catch block, e is unknown. You can't access .data without type assertions.
import { isErrorTag } from 'faultline';
try {
const user = await getUser(id);
} catch (e) {
// Check a specific error — data is fully typed
if (isErrorTag(e, UserErrors.NotFound)) {
console.log(e.data.userId); // string
}
}
isErrorTag is a type guard that narrows e from unknown to the specific error type. Inside the if block, e.data.userId is string — no casts, no assertions.
Catching with narrowError
When multiple error groups might throw, checking each one individually is tedious.
catch (e) {
const error = narrowError(e, [UserErrors, PaymentErrors]);
// ^? Infer | Infer | UnexpectedError
switch (error._tag) {
case 'User.NotFound': return { status: 404 };
case 'User.Unauthorized': return { status: 401 };
case 'Payment.Declined': return { status: 402 };
default: return { status: 500 };
}
}
narrowError takes an unknown caught value and an array of error groups, then narrows it to the union of all error types in those groups. If the caught value doesn't match any group, it wraps it as an UnexpectedError — so the default branch always has a fallback.
Structured Logging
String(e) gives you 'Error: something went wrong'. Not useful for debugging.
import { serializeError } from 'faultline';
const serialized = serializeError(error);
// {
// _format: 'faultline',
// _version: 1,
// _tag: 'User.NotFound',
// code: 'USER_NOT_FOUND',
// message: 'User 42 not found',
// status: 404,
// data: { userId: '42' },
// context: [...],
// cause: null
// }
JSON.stringify(serialized); // safe — handles circular refs, BigInt, Symbol
serializeError produces a JSON-safe object with the full error structure — tag, code, data, status, context frames, and cause chains. It handles circular references, BigInt, and Symbol values automatically. You can also call error.toJSON() directly.
ESLint Strict Config
The recommended config only warns on raw throws. You still need to remember to handle every error type.
// eslint.config.js
import faultline from 'eslint-plugin-faultline';
export default [
faultline.configs.strict,
];
Switching from recommended to strict adds two powerful rules: uncovered-catch (warns when a catch block doesn't handle all throwable error types) and throw-type-mismatch (errors when thrown errors don't match the TypedPromise declaration). You're still using throw and catch — but now the tooling ensures your catch blocks are complete.
Uncovered Catch Detection
A function throws NotFound and Unauthorized, but your catch only handles NotFound. Nothing warns you.
try {
const user = await getUser(id); // throws NotFound, Unauthorized
} catch (e) {
if (isErrorTag(e, UserErrors.NotFound)) {
return { status: 404 };
}
// ⚠ faultline/uncovered-catch:
// Unauthorized is throwable but not handled
}
The uncovered-catch rule analyzes which errors each function can throw and verifies that your catch block handles all of them. When you add a new error type to a function, the linter tells you every catch block that needs updating.
Throw/Type Drift Detection
You declare a TypedPromise with certain error types but the function body throws different ones. They drift apart silently.
import type { TypedPromise, Infer } from 'faultline';
// Declares only NotFound...
async function getUser(id: string): TypedPromise> {
const user = await db.get(id);
if (!user) throw UserErrors.NotFound({ userId: id });
if (!user.active) throw UserErrors.Unauthorized();
// ⚠ faultline/throw-type-mismatch:
// Unauthorized thrown but not declared in TypedPromise
return user;
}
The throw-type-mismatch rule catches drift between what a function declares it can throw and what it actually throws. If you add a new throw statement, the linter flags it until you update the type annotation.
TypedPromise
Async functions always return Promise<T> — TypeScript doesn't let you declare what they throw.
import type { TypedPromise, Infer } from 'faultline';
async function getUser(
id: string,
): TypedPromise | Infer> {
const user = await db.get(id);
if (!user) throw UserErrors.NotFound({ userId: id });
if (!user.active) throw UserErrors.Unauthorized();
return user;
}
// The .catch() handler receives the typed error union
const user = await getUser('42').catch((e) => {
// e: Infer | Infer
});
TypedPromise<T, E> is a zero-cost type annotation — it's just Promise<T> at runtime. But at the type level, it gives .catch() a typed error parameter instead of any. Combined with the throw-type-mismatch rule, you get compile-time and lint-time safety for thrown errors.
Result Types
For code where you want the compiler to track every possible error — no exceptions, no unknown — use Result<T, E>. You can use them imperatively with if/else or with method chaining.
import { ok, err, isOk, isErr, type Result, type Infer } from 'faultline';
function getUser(id: string): Result> {
const user = db.get(id);
if (!user) return err(UserErrors.NotFound({ userId: id }));
return ok(user);
}
Result<T, E> is a discriminated union on _type: 'ok' | 'err'. Use isOk(result) and isErr(result) to narrow it. The error type is carried in the return type — the caller always knows exactly what can fail.
Imperative Style (if/else)
const result = getUser(id);
if (isErr(result)) {
result.error._tag; // 'User.NotFound' — literal type
result.error.data.userId; // string — fully typed
result.error.status; // 404
return;
}
// TypeScript knows this is ok — result.value is User
const user = result.value;
Compose multiple Results with early returns:
function updateUserEmail(userId: string, newEmail: string) {
const userResult = getUser(userId);
if (isErr(userResult)) return userResult;
const emailResult = validateEmail(newEmail);
if (isErr(emailResult)) return emailResult;
return ok({ ...userResult.value, email: emailResult.value });
// Return type: Result
}
No paradigm shift required. This is just normal imperative code with typed errors instead of unknown. The return type automatically accumulates all possible failures.
Chaining (map / andThen)
const result = getUser(userId)
.andThen(user => validateEmail(newEmail).map(email => ({ ...user, email })));
// Result
Errors accumulate in the type automatically. Each .andThen() adds its failure modes to the union. .map() transforms the success value without affecting the error type. Both short-circuit on the first error — later steps don't run.
Exhaustive Match
import { match } from 'faultline';
match(result, {
ok: (user) => `Updated ${user.name}`,
'User.NotFound': (e) => `No user ${e.data.userId}`,
'User.InvalidEmail': (e) => `Bad email: ${e.data.reason}`,
});
Remove a handler and you get a compile error. Each handler receives the specific error type with full autocomplete on .data. Add a _ handler for a wildcard fallback when you don't need exhaustive coverage.
catchTag & Recovery
getUser(userId)
.catchTag('User.NotFound', (e) => ok({ id: e.data.userId, name: 'Guest', email: '' }));
// Result
// NotFound is gone from the type — handled. Only Unauthorized remains.
.catchTag() handles one specific error tag and removes it from the Result's error type. The handler receives the narrowed error and must return a new ok() value. Remaining unhandled errors stay in the type.
Collecting Errors (all)
const result = all([
validateName(input.name),
validateEmail(input.email),
validateAge(input.age),
] as const);
// Ok → typed tuple [string, string, number]
// Err → System.Combined containing ALL validation errors
all() collects an array of Results. If all succeed, you get a typed tuple of all success values. If any fail, you get a System.Combined error containing every failure — not just the first one. This is especially useful for validation where you want to report all problems at once.
Async Pipelines (TaskResult)
attemptAsync wraps promise-based code as a TaskResult — a lazy async computation that runs on .run():
import { attemptAsync } from 'faultline';
const task = attemptAsync(
async (signal) => {
const res = await fetch(`/api/users/${id}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise;
},
{ mapUnknown: (thrown) => UserErrors.NotFound({ userId: id }) },
);
// TaskResult
const result = await task
.map(user => user.name)
.withContext({ layer: 'service', operation: 'getUser' })
.run({ signal: controller.signal });
attempt does the same for synchronous code:
const result = attempt(() => JSON.parse(raw));
// Result
TaskResult is lazy — nothing runs until .run(). It supports AbortSignal for cancellation, and .map(), .andThen(), .catchTag(), and .withContext() all work the same as on Result.
Error Boundaries
Map domain errors to HTTP errors (or any other layer) with defineBoundary. The mapping is exhaustive — add a new domain error and the compiler tells you to add a handler.
import { defineBoundary, defineErrors } from 'faultline';
const HttpErrors = defineErrors('Http', {
NotFound: {
status: 404,
message: (data: { resource: string; id: string }) =>
`${data.resource} ${data.id} not found`,
},
BadRequest: {
status: 400,
message: (data: { errors: { field: string; message: string }[] }) =>
'Bad request',
},
Forbidden: { status: 403 },
});
const userToHttp = defineBoundary({
name: 'user-to-http',
from: UserErrors,
map: {
'User.NotFound': (e) =>
HttpErrors.NotFound({ resource: 'user', id: e.data.userId }),
'User.InvalidEmail': (e) =>
HttpErrors.BadRequest({
errors: [{ field: 'email', message: e.data.reason }],
}),
'User.Unauthorized': () => HttpErrors.Forbidden(),
},
});
const httpError = userToHttp(domainError);
// Original error preserved as .cause, boundary context auto-added
The original error is preserved as .cause on the new error, and a boundary context frame is automatically added. Add a new variant to UserErrors and the compiler immediately flags the missing handler in map.
Serialization & Deserialization
import { serializeError, deserializeError } from 'faultline';
const serialized = serializeError(error);
// { _format: 'faultline', _version: 1, _tag: 'User.NotFound', code: 'USER_NOT_FOUND', ... }
JSON.stringify(serialized); // safe — handles circular refs, BigInt, Symbol, etc.
const restored = deserializeError(serialized);
// Full AppError with tag, data, context, cause chain
Errors round-trip through JSON with full fidelity — tag, code, data, context, and cause chains are all preserved. The serialized format is versioned and JSON-safe, handling edge cases like circular references, BigInt, and Symbol values automatically.
Context Frames
Add structured context to any error for observability:
error.withContext({
layer: 'service', // 'ui' | 'client' | 'service' | 'domain' | 'infra' | 'transport'
operation: 'getUser',
component: 'UserService',
requestId: 'req-abc-123',
traceId: 'trace-xyz',
meta: { userId: '42' },
});
Context frames are preserved through boundaries, serialization, and cause chains. Each frame records where and why an error was observed, making it easy to trace errors through layered architectures. All fields are optional — use only what you need.
Configuration & Redaction
import { configureErrors } from 'faultline';
configureErrors({
captureStack: false, // disable in production for performance
redactPaths: [
'data.password',
'data.token',
'context.*.meta.apiKey', // wildcards supported
],
});
// serializeError() now replaces matched paths with '[REDACTED]'
Stack capture defaults to true in development and false when NODE_ENV=production. Redaction paths support wildcards and are applied during serialization — the original error data is never mutated.
Built-in System Errors
Faultline ships with a set of system-level error factories for common infrastructure concerns:
| Factory | Tag | Use case |
|---|---|---|
SystemErrors.Unexpected |
System.Unexpected |
Wrapped unknown throws |
SystemErrors.Timeout |
System.Timeout |
Operation timeouts |
SystemErrors.Cancelled |
System.Cancelled |
AbortSignal cancellations |
SystemErrors.SerializationFailed |
System.SerializationFailed |
Serialization failures |
SystemErrors.BoundaryViolation |
System.BoundaryViolation |
Unmapped boundary errors |
System.Combined is produced by all() when multiple Results fail — it contains an array of all individual errors.
Express / Hono / Fastify
Drop one of these error handlers into your framework and all faultline errors get structured JSON responses.
Express
app.use((err, req, res, next) => {
if (isAppError(err)) {
res.status(err.status ?? 500).json({ error: err.code, message: err.message });
return;
}
res.status(500).json({ error: 'INTERNAL_ERROR' });
});
Hono
app.onError((err, c) => {
if (isAppError(err)) {
return c.json({ error: err.code, message: err.message }, err.status ?? 500);
}
return c.json({ error: 'INTERNAL_ERROR' }, 500);
});
Fastify
fastify.setErrorHandler((err, request, reply) => {
if (isAppError(err)) {
reply.status(err.status ?? 500).send({ error: err.code, message: err.message });
return;
}
reply.status(500).send({ error: 'INTERNAL_ERROR' });
});
Testing Patterns
Assert on specific error tags and typed data in your tests:
test('returns NotFound for missing user', () => {
const result = getUser('nonexistent');
expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(isErrTag(result, 'User.NotFound')).toBe(true);
expect(result.error.data.userId).toBe('nonexistent');
}
});
Because errors carry typed data, your test assertions can check specific fields without casting. isErrTag narrows the error type so result.error.data is fully typed inside the if block.
Tuples / ?= Proposal
You may have seen the TC39 Safe Assignment Operator (?=) proposal or libraries that return [error, value] tuples:
const [err, user] = safeTry(() => getUser(id));
We considered this but chose discriminated unions (result._type) for three reasons:
- TypeScript narrows discriminated unions more reliably than tuple truthiness. After
if (isErr(result)), the compiler guaranteesresult.erroris typed. Withif (err), you're relying on truthiness narrowing — which works but is a weaker contract and easier to get backwards. - The
?=proposal doesn't type the error. It's sugar for try/catch —erris stillunknown. Faultline's value is that errors carry typed data, tags, and codes. Even if?=lands, you'd still want faultline underneath. - A single
resultvalue composes better. You can pass it tomatch(), return it from functions, or chain methods on it. Two separate variables can't do that.
If you prefer the tuple syntax, a one-line helper gets you there — and your errors stay fully typed:
function tryResult(
result: Result,
): [E, undefined] | [undefined, T] {
return isErr(result) ? [result.error, undefined] : [undefined, result.value];
}
const [err, user] = tryResult(getUser(id));
if (err) {
err.data.userId; // still typed
return;
}
user.name; // still typed
Comparison with Other Libraries
Most TypeScript error libraries ask you to adopt a new paradigm across your entire codebase before you see any benefit. Faultline is the only one designed for incremental adoption — start with throw/catch and go further when you're ready.
vs. hand-rolled error classes
The status quo. You write a class per error, each with its own shape, and hope they stay consistent.
class UserNotFoundError extends Error {
readonly code = 'USER_NOT_FOUND';
readonly status = 404;
constructor(public readonly userId: string) {
super(`User ${userId} not found`);
this.name = 'UserNotFoundError';
}
}
// repeat for every error type...
const UserErrors = defineErrors('User', {
NotFound: {
status: 404,
message: (data: { userId: string }) =>
`User ${data.userId} not found`,
},
});
// tag, code, status, typed data — done
Faultline replaces the boilerplate and adds ESLint rules, structured serialization, and typed catch blocks. You get more with less code.
vs. neverthrow
neverthrow is a solid Result type library. Its strength is a clean Result API with good TypeScript inference. The key difference: neverthrow requires you to use Result types everywhere from day one. Faultline lets you start with throw/catch.
import { ok, err, ResultAsync } from 'neverthrow';
// You still hand-roll the error class
class UserNotFoundError extends Error {
constructor(public userId: string) { ... }
}
// Must return ResultAsync — can't use throw
function getUser(id: string): ResultAsync {
...
}
import { defineErrors } from 'faultline';
// Error definition does it all
const UserErrors = defineErrors('User', {
NotFound: { status: 404, message: ... },
});
// Just throw — no paradigm shift
function getUser(id: string): Promise {
if (!user) throw UserErrors.NotFound({ userId: id });
}
With faultline, the same UserErrors.NotFound definition works whether you throw it (Stage 1) or return it as a Result (Stage 3). You don't have to choose upfront, and you never redefine your errors when you move between paradigms.
neverthrow also doesn't provide error definitions, ESLint integration, serialization, or error boundaries — you build those yourself.
vs. Effect
Effect is genuinely impressive. It solves dependency injection, concurrency, streaming, metrics, and typed errors in one cohesive system. But it's an all-or-nothing runtime — your entire codebase runs inside Effect.
Faultline is focused on errors only. It's a library, not a runtime. You can adopt it in 5 minutes with zero changes to your existing patterns, architecture, or dependencies. If you need Effect's full power, use Effect. If you need typed errors without the commitment, use faultline.
Quick reference
| Hand-rolled | neverthrow | Effect | faultline | |
|---|---|---|---|---|
| Start with throw/catch | ✓ | ✗ | ✗ | ✓ |
| Typed error data | Manual | ✗ | ✓ | ✓ |
| ESLint integration | ✗ | ✗ | ✗ | ✓ |
| Error definitions | ✗ | ✗ | ✓ | ✓ |
| Result types | ✗ | ✓ | ✓ | ✓ |
| Error boundaries | ✗ | ✗ | ✓ | ✓ |
| Serialization | Manual | ✗ | ✗ | ✓ |
| Progressive adoption | N/A | All-or-nothing | All-or-nothing | Staged |
Error Definition
| Export | Description |
|---|---|
defineError(def) |
Create a single error factory |
defineErrors(namespace, defs) |
Create a group of error factories under a namespace |
Infer<T> |
Extract the error type from a factory or group |
ErrorOutput |
Symbol key for error type extraction |
Result
| Export | Description |
|---|---|
ok(value) |
Create a success result |
err(error) |
Create a failure result |
isOk(result) / isErr(result) |
Type guard narrowing |
isErrTag(result, tag) |
Narrow to a specific error tag |
match(result, handlers) |
Exhaustive or partial pattern match |
catchTag(result, tag, handler) |
Handle one error tag, remove it from the type |
all(results) |
Collect all results; combine errors on failure |
Result Methods
| Method | Description |
|---|---|
.map(fn) |
Transform the success value |
.mapErr(fn) |
Transform the error |
.andThen(fn) |
Chain to another Result-returning function |
.catchTag(tag, fn) |
Recover from a specific error tag |
.match(handlers) |
Pattern match on success/failure |
.tap(fn) / .tapError(fn) |
Side effects without changing the result |
.withContext(frame) |
Add a context frame to the error |
.unwrap() |
Extract value or throw |
.unwrapOr(fallback) |
Extract value or use fallback |
.toTask() |
Convert to a lazy TaskResult |
.toJSON() |
Serialize to JSON-safe object |
TaskResult
| Export | Description |
|---|---|
TaskResult.from(executor) |
Create from an async executor |
TaskResult.fromResult(result) |
Wrap an existing Result |
TaskResult.fromPromise(factory) |
Create from a promise factory |
TaskResult.ok(value) |
Create a successful TaskResult |
TaskResult.err(error) |
Create a failed TaskResult |
.run(options?) |
Execute the task, returns Promise<Result> |
TaskResult supports .map(), .mapErr(), .andThen(), .catchTag(), .match(), and .withContext() — same API as Result.
Error Handling
| Export | Description |
|---|---|
attempt(fn, options?) |
Wrap sync code — catches throws, returns Result |
attemptAsync(fn, options?) |
Wrap async code — returns TaskResult with AbortSignal support |
fromUnknown(thrown, options?) |
Convert any thrown value to an AppError |
narrowError(e, groups) |
Type-narrow a caught value against error groups |
isAppError(e) |
Type guard for AppError |
isErrorTag(e, tagOrFactory) |
Type guard for a specific error tag |
Boundaries
| Export | Description |
|---|---|
defineBoundary({ name, from, map }) |
Create an exhaustive error mapping between layers |
Serialization
| Export | Description |
|---|---|
serializeError(error) |
Convert AppError to JSON-safe object |
deserializeError(data) |
Restore from serialized form |
serializeResult(result) |
Serialize a Result |
deserializeResult(data) |
Restore from serialized form |
Configuration
| Export | Description |
|---|---|
configureErrors(options) |
Set global stack capture and redaction paths |
getErrorConfig() |
Read current config (frozen) |
resetErrorConfig() |
Reset to defaults (useful in tests) |