Skip to content

danfry1/faultline

Repository files navigation

faultline

npm CI license: MIT zero dependencies

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' });

Why

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.

How faultline differs

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).

Install

npm install faultline
npm install -D eslint-plugin-faultline  # recommended

The Adoption Path

Faultline is designed to be adopted in stages. Start with Stage 1 — you'll get immediate value. Go further if and when it makes sense.

Stage 1: Define and throw

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
  • isErrorTag and narrowError give 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

Stage 2: Typed catch blocks

When you're ready, turn up the ESLint rules:

faultline.configs.strict

Now the linter also:

  • Warns on uncovered catch blocks — if a function throws NotFound and Unauthorized, 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.

Stage 3: Result types

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 errors

Enable the strictest ESLint config when you're here:

faultline.configs.all

This errors on any raw throw and any uncovered catch — pushing you toward Result types for all error handling.

Why not [err, value] tuples?

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 typed

Going Deeper

These features are available at any stage but become especially powerful with Result types.

Async Pipelines

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>

When to use TaskResult vs Promise<Result>

Reach for TaskResult when you need one of:

  • Cancellation through a pipeline. Pass an AbortSignal to .run({ signal }) and every chained step checks it between steps. Abort once, and the remaining steps short-circuit to System.Cancelled. Cancellation is cooperative: a step already in flight runs to completion, and the underlying work only stops if you thread signal into 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 });

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 } 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 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);

Serialization

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.

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.

Configuration

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 like password (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.

Built-in System Errors

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.

Ecosystem

ESLint Plugin

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.

CLI

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

VS Code Extension (experimental)

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

API Reference

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(), .withContext() — same 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)

Contributing

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 packages

Releases 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.

License

MIT © Daniel Fry

About

The complete type-safe error system for TypeScript.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors