Structured Errors
The errors your package throws cross a boundary: the host catches them, serializes them, and decides what the user or the model sees. A bare Error gives the host nothing to route on, so the response comes back as an opaque 500 and the support ticket starts with "something failed". A catalog replaces that with data the host can branch on, count, and display.
The examples here come from @github-tools/sdk, whose catalog wraps every GitHub and Vercel Connect failure the SDK can classify. The Libraries overview introduces the package; this page shows its catalog module, trimmed to four of its eleven entries.
Define a catalog with a prefix your package owns
defineErrorCatalog builds one factory per entry. The prefix is the first half of the wire format, so give it your package name:
import { defineErrorCatalog } from 'evlog'
export const githubToolsErrors = defineErrorCatalog('github_tools', {
TOKEN_REQUIRED: {
status: 401,
message: 'GitHub token is required. Pass it as `token` or set the GITHUB_TOKEN environment variable.',
why: 'No token string, async token provider, or GITHUB_TOKEN environment variable was available when the tool resolved its GitHub credentials.',
fix: 'Pass `token` (a PAT string or async provider) when creating the tools, set GITHUB_TOKEN, or configure a Vercel Connect `connector`.',
link: 'https://github-tools.com/guide/tokens-and-auth',
},
RATE_LIMITED: {
status: 429,
message: ({ detail }: { detail: string }) => `GitHub rate limit exhausted: ${detail}`,
why: 'The token used up its GitHub API rate limit for this resource.',
fix: 'Stop calling this tool and retry after the reset timestamp in the message; batch reads or narrow the query to spend fewer requests.',
link: 'https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api',
},
NOT_FOUND: {
status: 404,
message: ({ detail }: { detail: string }) => `GitHub resource not found (404): ${detail}`,
why: 'Either the resource does not exist, or the token cannot see it. GitHub deliberately returns 404 instead of 403 for private resources the token has no access to.',
fix: 'Check the owner/repo/number input first. If it is correct, the token lacks access: grant the repository to the PAT or App installation, or use a Connect subject that has access.',
link: 'https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api#404-not-found-for-an-existing-resource',
},
VALIDATION_FAILED: {
status: 422,
message: ({ detail }: { detail: string }) => `GitHub rejected the request as invalid (422): ${detail}`,
why: 'The input was well-formed but GitHub refused it: a duplicate resource, an immutable state transition, or an unresolvable ref.',
fix: 'The embedded GitHub message names the offending field or state; adjust the input and retry.',
link: 'https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api#validation-failed',
},
})
declare module 'evlog' {
interface RegisteredErrorCatalogs {
github_tools: typeof githubToolsErrors
}
}
Each entry accepts status (default 500), message as a constant string or a typed function whose params become required factory arguments, why, fix, link, tags, and internal for backend-only context. The declare module block gives consumers autocomplete on github_tools.NOT_FOUND wherever they match on code. Structured Errors documents the full anatomy as an application sees it; Errors Agents Can Act On covers what goes into why and fix.
The wire format is ${prefix}.${KEY}: throw githubToolsErrors.NOT_FOUND({ detail }) and the host receives an error whose code is github_tools.NOT_FOUND. One prefix per package keeps a graph coherent: a host running several evlog-instrumented packages never sees two codes collide, because each package owns one namespace the way it owns one npm name.
Wrap upstream failures instead of rethrowing them
Most library errors start as someone else's error: an Octokit RequestError, a fetch failure, a provider SDK exception. Map them to catalog entries at the boundary where you know the cause, keep the original as cause, and put request coordinates in internal so they reach the host's logs and never the client:
export function toGithubToolsError(error: unknown): unknown {
const status = errorStatus(error)
if (status === undefined || !(error instanceof Error)) return error
const detail = error.message
const overrides = { cause: error, internal: { status, ...requestTarget(error) } }
if (status === 401) return githubToolsErrors.UNAUTHORIZED({ detail, ...overrides })
if (status === 404) return githubToolsErrors.NOT_FOUND({ detail, ...overrides })
if (status === 422) return githubToolsErrors.VALIDATION_FAILED({ detail, ...overrides })
if (status === 429) return githubToolsErrors.RATE_LIMITED({ detail, ...overrides })
return error
}
Statuses without an entry pass through unchanged. That is deliberate: a catalog error with a vague why is worse than the original, because the consumer trusts it. Add an entry when you can name the cause and the fix, and let the rest through until you can.
What the host receives
Throwing the factory's result is the whole integration. The EvlogError carries code, status, message, why, fix, and link as own properties, and serializes with the guidance nested under data:
throw githubToolsErrors.NOT_FOUND({ detail: 'Not Found', cause: octokitError })
{
"name": "EvlogError",
"message": "GitHub resource not found (404): Not Found",
"status": 404,
"data": {
"code": "github_tools.NOT_FOUND",
"why": "Either the resource does not exist, or the token cannot see it. GitHub deliberately returns 404 instead of 403 for private resources the token has no access to.",
"fix": "Check the owner/repo/number input first. If it is correct, the token lacks access: grant the repository to the PAT or App installation, or use a Connect subject that has access.",
"link": "https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api#404-not-found-for-an-existing-resource"
},
"cause": { "name": "HttpError", "message": "Not Found" }
}
import { parseError } from 'evlog'
const { code, status, why, fix, link } = parseError(error)
// code → 'github_tools.NOT_FOUND'
// status → 404
// why, fix, link → the strings above
internal is absent from both. It is omitted from toJSON() and from every framework serializer, so it is the right place for the request URL, the raw status, or anything else you would not send to a client. The framework integrations put status on the HTTP response and index code in the drained event without any code on the host's side.
Hosts should catch with EvlogError.isEvlogError(error) rather than instanceof. Package managers routinely install more than one physical copy of evlog, and instanceof silently reports false across copies, which downgrades your structured error to a bare 500. The brand check works across copies. Put that sentence in your package's error guide; Exporting the Catalog generates the rest of that guide from the catalog itself.
Publish the catalog as its own entrypoint
Put the catalog in its own module and export it under a subpath, so hosts can import your codes without pulling the rest of the package:
{
"exports": {
".": "./dist/index.mjs",
"./errors": "./dist/errors.mjs"
},
"peerDependencies": {
"evlog": "^2.0.0"
}
}
evlog stays a peer dependency: the factories build EvlogError instances from whatever copy of evlog the host resolved, so your errors and the host's share one type. A host that wants to branch on your failures imports githubToolsErrors and compares error.code === githubToolsErrors.NOT_FOUND.code, which survives a refactor on your side the way a string literal does not. If your package is large enough to need several catalogs, the npm packaging recipe on Catalogs covers splitting them per bounded context.
Treat error codes as wire format
The code string crosses your package boundary and lands in the host's dashboards, alerts, support macros, and the prompts of agents that learned to match on it. Renaming a code is a breaking change, the same way renaming an exported function is. Add codes freely, reorder entries safely, and when a code must go, keep the old entry with a why that names its replacement for one major before you delete it. Testing shows how to make a rename fail in your own suite before it fails in a host.
Emitting Events
Emit events from library code the host can attribute and filter: join the host's request, tag your own events, and stay out of the configuration.
Errors Agents Can Act On
Write why, fix, and link so a model calling your package recovers from the error itself, without a docs round-trip and the tokens it costs.