The incremental type-safe error system for TypeScript.
Define your errors. Throw them like normal. The ESLint plugin tells you when you miss something. Adopt Result types and boundaries when you're ready — or don't. Either way, your app is better off.
import { defineErrors } from 'faultline';
const UserErrors = defineErrors('User', {
NotFound: {
status: 404,
message: (data: { userId: string }) => `User ${data.userId} not found`,
},
Unauthorized: { status: 401 },
});
// Just throw — it's a real Error with typed data, a code, and a status
throw UserErrors.NotFound({ userId: '42' });TypeScript tells you the shape of your data, but not the shape of your failures. Catch blocks give you unknown. Hand-rolled error classes drift out of sync. A new error type gets added and nothing tells you to handle it.
Faultline fixes this incrementally. You don't need to rewrite your app. You don't need to learn a new paradigm. Start with what you already know — throw and catch — and let the tooling guide you forward.
vs. neverthrow, ts-results, true-myth. These are solid Result-type libraries. Faultline's difference is that it doesn't ask you to convert your throws first. Stage 1 — typed factories plus the ESLint plugin — delivers most of the value while your code keeps using throw and catch. Result types are there when you want them at Stage 3.
vs. Effect. Effect is more powerful and more mature. Faultline is narrowly focused on errors: no effect system, no runtime, no scheduler. Just typed errors plus optional Results. The learning curve is hours, not weeks.
vs. typed Error subclasses. Custom Error classes work. Faultline derives consistent tags, codes, status values, and serialization from a single declaration, ships an ESLint plugin that catches drift, and gives you a smooth path to Result types if you want them later.
vs. the ?= proposal. You may have seen the TC39 Safe Assignment Operator (?=) proposal or libraries that return [error, value] tuples. Faultline uses discriminated unions instead because: (1) TypeScript narrows discriminated unions more reliably than tuple truthiness — after if (isErr(result)), the compiler guarantees the error type; (2) the ?= proposal doesn't type the error — it's still unknown; (3) a single result value composes with match(), returns cleanly from functions, and chains methods. If you prefer tuple syntax, a one-line helper gets you there and your errors stay fully typed (see "Why not [err, value] tuples?" below).
npm install faultline
npm install -D eslint-plugin-faultline # recommendedFaultline is designed to be adopted in stages. Start with Stage 1 — you'll get immediate value. Go further if and when it makes sense.
Replace throw new Error(...) with typed error factories. Everything else stays the same.
Define your errors once:
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:
UserErrors.NotFound(...)._tag→'User.NotFound'UserErrors.NotFound(...).code→'USER_NOT_FOUND'
Codes default to auto-generated SCREAMING_SNAKE_CASE from the tag. If you have an existing error-code convention, pass an explicit code — it's preserved at the type level too, so downstream consumers see the literal string:
const UserErrors = defineErrors('User', {
// Auto-generated: code === 'USER_NOT_FOUND'
NotFound: {
status: 404,
message: (data: { userId: string }) => `User ${data.userId} not found`,
},
// Explicit: code === 'ERR_AUTH_401' (literal type preserved)
Unauthorized: {
code: 'ERR_AUTH_401',
status: 401,
},
});Throw them like you already do:
async function getUser(id: string): Promise<User> {
const user = await db.users.findUnique({ where: { id } });
if (!user) throw UserErrors.NotFound({ userId: id });
return user;
}Enable the ESLint plugin:
// eslint.config.js
import faultline from 'eslint-plugin-faultline';
export default [
faultline.configs.recommended,
];Now the linter warns whenever you throw new Error(...) instead of using a typed factory. That's it. Your errors now have consistent tags, codes, status values, typed data, and structured serialization. Every error is a real Error instance with full stack traces.
Catch with type safety:
import { isErrorTag, narrowError } 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
}
}Or type everything at once with narrowError:
catch (e) {
const error = narrowError(e, [UserErrors, PaymentErrors]);
// ^? Infer<typeof UserErrors> | Infer<typeof PaymentErrors> | 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 };
}
}What you get at Stage 1:
- Every error has a tag, code, status, and typed data
isErrorTagandnarrowErrorgive you typed catch blocks- The ESLint plugin catches
throw new Error(...)and nudges you toward factories - Structured serialization works out of the box (
error.toJSON()) - Zero changes to your existing async/await, try/catch patterns
When you're ready, turn up the ESLint rules:
faultline.configs.strictNow the linter also:
- Warns on uncovered catch blocks — if a function throws
NotFoundandUnauthorized, the linter tells you when your catch only handles one of them - Catches throw/type drift — if you declare a TypedPromise with certain errors but throw different ones, the linter flags it
You're still using throw and catch. But now the tooling ensures your catch blocks are complete.
For code where you want the compiler to track every error through the pipeline — no exceptions, no unknown, no surprises — use Result<T, E>:
import { ok, err, isOk, isErr, type Result, type Infer } from 'faultline';
function getUser(id: string): Result<User, Infer<typeof UserErrors.NotFound>> {
const user = db.get(id);
if (!user) return err(UserErrors.NotFound({ userId: id }));
return ok(user);
}Use Results with plain if/else — no chaining required:
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<User, User.NotFound | User.InvalidEmail>
}This is just normal imperative code. No new paradigm — just typed errors instead of unknown.
Exhaustive match — remove a handler, get a compile error:
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}`,
});Chaining is also available if you prefer a more functional style:
const result = getUser(userId)
.andThen(user => validateEmail(newEmail).map(email => ({ ...user, email })));
// Result<User, User.NotFound | User.InvalidEmail>Recover from specific errors:
getUser(userId)
.catchTag('User.NotFound', (e) => ok({ id: e.data.userId, name: 'Guest', email: '' }));
// Result<User, User.Unauthorized>
// NotFound is gone from the type — handled. Only Unauthorized remains.Collect all errors at once:
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 errorsEnable the strictest ESLint config when you're here:
faultline.configs.allThis errors on any raw throw and any uncovered catch — pushing you toward Result types for all error handling.
We chose discriminated unions over tuples (see "How faultline differs" above). If you prefer the tuple syntax, a one-line helper gets you there — and your errors stay fully typed:
function tryResult<T, E extends AppError>(
result: Result<T, E>,
): [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 typedThese features are available at any stage but become especially powerful with Result types.
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<User>;
},
{ mapUnknown: (thrown) => UserErrors.NotFound({ userId: id }) },
);
// TaskResult<User, User.NotFound | System.Cancelled>
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<unknown, System.Unexpected>Reach for TaskResult when you need one of:
- Cancellation through a pipeline. Pass an
AbortSignalto.run({ signal })and every chained step checks it between steps. Abort once, and the remaining steps short-circuit toSystem.Cancelled. Cancellation is cooperative: a step already in flight runs to completion, and the underlying work only stops if you threadsignalinto it (e.g.fetch(url, { signal })). It stops subsequent steps and stops you waiting — it does not preempt work in progress. - Context frames across async steps. Use
.withContext({ ... })to attach observability metadata that survives composition. - Lazy composition. Build the pipeline, hand it off, run it later. Plain Promises run eagerly the moment you construct them.
Otherwise, Promise<Result> is fine. It composes with await and plain if (isErr(result)) checks just like the synchronous Stage 3 examples.
// Promise<Result> — perfectly good for most async work
async function getUser(id: string): Promise<Result<User, User.NotFound>> {
const user = await db.users.find(id);
if (!user) return err(UserErrors.NotFound({ userId: id }));
return ok(user);
}
// TaskResult — when you need cancellation or context propagation
const task = attemptAsync(
async (signal) => fetch(`/api/users/${id}`, { signal }),
{ mapUnknown: () => UserErrors.NotFound({ userId: id }) },
).withContext({ layer: 'service', operation: 'getUser' });
const result = await task.run({ signal: controller.signal });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 } 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-addedThe original error is always set as the mapped error's .cause — even if a handler sets its own cause, the boundary overwrites it. If an error whose tag isn't in map reaches the boundary (only possible via an any, a cast, or an error from outside the source group — exhaustive typing prevents it otherwise), the boundary throws System.BoundaryViolation. Since boundaries usually sit at a transport edge, either wrap the call or use the non-throwing variant:
const result = userToHttp.try(domainError); // Result<HttpError, System.BoundaryViolation>
if (isErr(result)) logUnmappedError(result.error);
else respondWith(result.value);Errors round-trip through JSON with full fidelity — tag, code, data, context, and cause chains.
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.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.
import { configureErrors } from 'faultline';
configureErrors({
captureStack: false, // disable in production for performance
redactPaths: [
'**.password', // ** = redact this key at ANY depth (recommended)
'**.token',
'data.ssn', // exact path, rooted at the serialized error
'context.*.meta.apiKey', // * = one wildcard level
],
});
// serializeError() now replaces matched paths with '[REDACTED]'Important: paths are matched against the serialized error (whose root has
data,context,cause, …), not against your raw value. A path that matches nothing is a silent no-op — so for sensitive keys prefer the globstar form**.password, which redacts that key wherever it appears, over a brittle exact path likepassword(which would match only a top-level key and silently leak anything nested).
Stack capture defaults to true in development, false when NODE_ENV=production.
| 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.
Three preset configs matching the adoption stages:
| Config | Stage | Behavior |
|---|---|---|
recommended |
1 | Warns on throw new Error() — start replacing with typed factories |
strict |
2 | Enforces typed catch blocks, detects throw/type drift |
all |
3 | Errors on all raw throws — use Result types everywhere |
Rules:
| Rule | Description |
|---|---|
faultline/no-raw-throw |
Enforce typed error factories over throw new Error() |
faultline/uncovered-catch |
Ensure catch blocks handle all throwable error types |
faultline/throw-type-mismatch |
Detect drift between thrown errors and TypedPromise declarations |
Known limitations: see eslint-plugin-faultline/LIMITATIONS.md for patterns the rules can't currently detect.
npx faultline <command> [path] [--json]| Command | Description |
|---|---|
catalog |
List all error definitions in the project |
graph |
Visualize boundary mappings between error groups |
lint |
Detect raw throws and transport leaks |
doctor |
Diagnose duplicate tags, missing boundary cases, and more |
Not yet published to the Marketplace — build from
packages/faultline-vscode.
- Diagnostics for missing error coverage
- Hover info showing throwable errors
- Quick fixes for common issues
| 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 |
| 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 |
| 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 |
| 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(), .withContext() — same as Result.
| 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 |
| Export | Description |
|---|---|
defineBoundary({ name, from, map }) |
Create an exhaustive error mapping between layers |
| 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 |
| Export | Description |
|---|---|
configureErrors(options) |
Set global stack capture and redaction paths |
getErrorConfig() |
Read current config (frozen) |
resetErrorConfig() |
Reset to defaults (useful in tests) |
This is a Bun monorepo:
| Package | Published as | Status |
|---|---|---|
packages/faultline |
faultline |
stable |
packages/eslint-plugin-faultline |
eslint-plugin-faultline |
stable |
packages/faultline-cli |
faultline-cli |
beta |
packages/faultline-vscode |
— | experimental |
bun install # install workspace deps
bun run typecheck # type-check every package
bun run test # run all tests (core enforces a coverage gate)
bun run lint # oxlint
bun run build # build all packagesReleases are managed with Changesets: include a changeset with any user-facing change (bun run changeset). See CONTRIBUTING.md for the full workflow.
- 🐛 Found a bug? Open an issue.
- 🔒 Security concern? See SECURITY.md — please don't file public issues for vulnerabilities.
MIT © Daniel Fry