Skip to content

Error System

MaxError is a composable error system with boundaries, facets, and cause chains. Errors are built from traits instead of class inheritance, with three discrimination axes: exact code, facet, or domain.

Here’s what a MaxError looks like when printed with maxError.prettyPrint({ color: true, includeStackTrace: true }):

MaxError[connector.error]: connector error
  ├ data: { connectorId: 'lin_456', connectorType: 'linear' }
  └ caused by: [sync.failed]: Sync failed
    └ caused by: [linear.api_timeout]: API timeout: /issues
      └ data: { endpoint: '/issues' }
  ➝ Stack trace:
    at <anonymous> (/packages/core/src/max-error.ts:419:34)
    at tryCatchWrap (/packages/core/src/max-error.ts:303:5)
    at <anonymous> (/packages/core/src/__test__/max-error.test.ts:732:17)

Error codes (connector.error) appear in red. Primary messages appear in white. Structure, data, and stack traces are dimmed. The cause chain reads top-down - the outermost boundary error wraps the domain error, which wraps the root cause.

The rest of this page covers how to build errors like this.

Creating a boundary

Every domain that throws errors defines a boundary. This is the first line of your errors.ts:

connector-linear/src/errors.ts
import { MaxError } from "@max/core";
export const Linear = MaxError.boundary("linear");

The boundary owns all errors in your domain. It prefixes codes automatically and provides is() and wrap().

Boundaries can declare contextual data that wrap() will require:

import { MaxError, ErrFacet } from "@max/core";
export const LinearConnector = MaxError.boundary("linear_connector", {
customProps: ErrFacet.props<{ connectorType: string; connectorId: string }>(),
});

When wrapping, the boundary data is carried on the thin boundary error (see Wrapping).

Defining errors

Three things per error:

  1. Code - just the suffix. The boundary prefixes the domain: "auth_failed" becomes "linear.auth_failed"
  2. Facets - what categories does this error belong to? Standard markers (NotFound, BadInput) and data facets
  3. Message - a function from data to a human-readable string. Keep it short; callers add detail via context

Error-local data with customProps

Errors often carry data specific to their message. Use customProps to declare it:

export const ErrIssueNotFound = Linear.define("issue_not_found", {
customProps: ErrFacet.props<{ issueId: string }>(),
facets: [NotFound],
message: (d) => `Issue not found: ${d.issueId}`,
});

The d parameter is the intersection of customProps data and all facet data. Both are type-safe - create() requires them, message() can access them.

// ✅ Typed - requires { issueId: string }
throw ErrIssueNotFound.create({ issueId: "ISS-123" });
// ❌ Compile error - missing issueId
throw ErrIssueNotFound.create({});

When to use customProps vs. data facets

customProps - data specific to this error, used in its message. No catch-site discrimination needed.

export const ErrMissingParam = Daemon.define("missing_param", {
customProps: ErrFacet.props<{ param: string }>(),
facets: [BadInput],
message: (d) => `Missing required parameter: ${d.param}`,
});

Data facets - reusable semantic data that catch sites extract. Multiple errors share the same facet, and callers branch on it.

const HasEntityRef = ErrFacet.data<{ entityType: string; entityId: string }>("HasEntityRef");
export const ErrEntityNotFound = Core.define("entity_not_found", {
facets: [NotFound, HasEntityRef],
message: (d) => `${d.entityType} not found: ${d.entityId}`,
});
// Catch site extracts structured data via the facet
if (MaxError.has(err, HasEntityRef)) {
logEntityFailure(err.data.entityType, err.data.entityId);
}

The upgrade path: Start with customProps. When you notice multiple errors carrying the same shape of data, and catch sites want to branch on it, extract it into a data facet.

Choosing facets

Facets answer “what category of thing happened?” - they’re for catch-site logic, not documentation.

  • Use a marker facet when callers will branch on it: NotFound -> return 404, BadInput -> show validation error
  • Use a data facet when callers need structured info: HasEntityRef -> log the entity ID
  • Use no facets when the error is self-explanatory by its code alone
  • Don’t force-fit a facet. ErrAuthFailed isn’t BadInput - the user didn’t provide bad input, the system isn’t configured

Standard facets

FacetKindWhen to use
NotFoundmarkerSomething expected doesn’t exist
BadInputmarkerCaller-supplied data failed validation
NotImplementedmarkerCode path not yet built
InvariantmarkerShould never happen - always a bug
HasEntityRefdata: { entityType, entityId }Error relates to a specific entity
HasFielddata: { entityType, field }Error relates to a specific field
HasLoaderNamedata: { loaderName }Error relates to a specific loader

Throwing errors

// Data is typed from customProps + facets
throw ErrIssueNotFound.create({ issueId: "ISS-123" });
// Optional context string - appended with " - "
throw ErrIssueNotFound.create({ issueId: "ISS-123" }, "during sync");
// Message: "Issue not found: ISS-123 - during sync"
// Optional cause - for manual chaining without wrap()
throw ErrLinearSyncFailed.create({}, "page 3 of issues", innerError);

Wrapping

There are two wrapping mechanisms, serving distinct purposes.

ErrorDef.wrap - intent wrapping

“Run this function. If it fails, the error is X.”

This has nothing to do with boundaries. It’s a convenience on any ErrorDef that try/catches and wraps the thrown error as a cause:

const issues = await ErrSyncFailed.wrap(async () => {
return await linearApi.fetchAllIssues();
});

If the API call fails, you get linear.sync_failed with the original error as the cause.

If the ErrorDef requires data (from customProps or data facets), pass it as the first argument:

await ErrSyncFailed.wrap({ source: "linear" }, async () => {
return await linearApi.fetchAllIssues();
});

Boundary.wrap - domain entry

“You’re entering this boundary. Here’s the context.”

Use this at the entry point to a domain. If anything escapes, the boundary wraps it in a thin {domain}.error carrying the provided contextual data:

await LinearConnector.wrap(
{ connectorType: "linear", connectorId: "lin_123" },
async () => {
const issues = await fetchAllIssues();
await storeIssues(issues);
},
);

If storeIssues throws a storage error, the cause chain becomes:

linear_connector.error { connectorType: "linear", connectorId: "lin_123" }
└ caused by: storage.write_failed
└ caused by: <original cause>

For boundaries without declared data, the data argument is omitted:

await Linear.wrap(async () => {
await route(req);
});

Rules

  • Same-domain errors pass through - if code inside Linear.wrap() throws a Linear error, it won’t be double-wrapped
  • Cross-domain errors are wrapped with the original as cause
  • Plain Error / strings are first converted to a generic MaxError, then used as cause
  • Compose naturally - use ErrorDef.wrap() for specific operations inside a Boundary.wrap():
await LinearConnector.wrap({ connectorType: "linear", connectorId: "lin_123" }, async () => {
const issues = await ErrSyncFailed.wrap(() => linearApi.fetchIssues());
await ErrStoreFailed.wrap(() => storeIssues(issues));
});

Catching errors

Three discrimination axes - use the narrowest one that fits:

try {
await linear.sync();
} catch (err) {
// Exact type - "is this specific error?"
if (ErrIssueNotFound.is(err)) {
console.log(err.data.issueId); // typed from customProps + facets
}
// By facet - "is this any kind of not-found?"
if (MaxError.has(err, NotFound)) {
return 404;
}
// By boundary - "did Linear fail?"
if (Linear.is(err)) {
reportToLinearMonitor(err);
}
// Walk the cause chain
if (MaxError.isMaxError(err) && err.cause) {
console.log("root cause:", err.cause.code);
}
}

Displaying errors

prettyPrint

prettyPrint() renders an error with its full cause chain in a readable format:

if (MaxError.isMaxError(err)) {
console.error(err.prettyPrint({ color: true }));
}
MaxError[daemon.connector_not_found]: Unknown connector: bogus
  ├ data: { connector: 'bogus' }
  └ caused by: [linear.sync_failed]: Sync failed
    └ caused by: [unknown]: ECONNREFUSED

Options:

  • color - ANSI color codes (error codes in red, messages in white, structure dimmed)
  • includeStackTrace - append the stack trace at the end

toJSON

toJSON() returns a structured object for logging pipelines. Cause chains serialize recursively:

{
"code": "linear.sync_failed",
"domain": "linear",
"message": "Linear sync failed",
"data": {},
"facets": [],
"cause": {
"code": "storage.write_failed",
"domain": "storage",
"message": "Write failed",
"data": {},
"facets": []
}
}

Core errors

The Core boundary provides infrastructure errors. Prefer defining domain-owned errors - seeing core.* in production logs is a signal to go define a proper domain error.

ErrorCodeFacetscustomProps
ErrInvalidRefKeycore.invalid_ref_keyBadInput{ key: string }
ErrFieldNotLoadedcore.field_not_loadedInvariant, HasField-
ErrLoaderResultNotAvailablecore.loader_result_not_availableNotFound, HasLoaderName-
ErrContextBuildFailedcore.context_build_failedBadInput-
ErrBatchKeyMissingcore.batch_key_missingNotFound{ key: string }
ErrBatchEmptycore.batch_emptyInvariant-