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.

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

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

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

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.

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

There are two wrapping mechanisms, serving distinct purposes.

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

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

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

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() 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": []
}
}

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-