| name | define-errors |
| description | defineErrors from wellcrafted: variant factories, extractErrorMessage, InferErrors/InferError, call site patterns. Use when creating error types or reviewing error patterns. |
| metadata | {"author":"epicenter","version":"3.1"} |
defineErrors
Related Skills: See error-handling for trySync/tryAsync usage and toast-on-error patterns. See services-layer for service architecture and namespace exports.
Ground API claims in the official wellcrafted-dev/wellcrafted source and
tests for Epicenter's installed version. This skill owns variant construction,
naming, fields, and type extraction. error-handling owns catch adaptation and
Result consumption; logging owns diagnostic severity and sinks.
Import
import {
defineErrors,
extractErrorMessage,
type InferErrors,
type InferError,
} from 'wellcrafted/error';
Core Rules
- Put the variants for one public failure contract in one
defineErrors call. Separate contracts, such as KV read and write failures, may each have their own namespace, even when each has one variant
- The factory function returns
{ message, ...fields }: that is the entire API; no .withMessage(), .withContext(), or .withCause() chains
cause: unknown is just a field like any other: accept it in the input and forward it in the return object
- When a factory accepts a cause, call
extractErrorMessage(cause) inside the factory so call sites pass the raw value
- Each call like
MyError.Variant({ ... }) returns Err(...) automatically: no separate FooErr pair
- Shadow the const with a same-name type using
InferErrors: const FooError / type FooError
- Use
InferError<typeof FooError.Variant> to extract a single variant's type when needed
- Prefer a name that identifies the failure mode when callers can distinguish or act on it. A single
Failed variant is acceptable when the contract exposes one deliberately undifferentiated failure
- Let the public failure contract determine the variant count. Do not merge distinct actionable failures merely to hit a small number, and do not split one failure into artificial variants
- Write
.message for its actual consumers: variants that reach toastOnError or $lib/report need safe, readable user copy. Diagnostic-only variants may be technical because the logger is their consumer. Do not assume every tagged error is user-facing. For presented errors, avoid raw paths, status codes, or stack traces as the primary message. Put necessary technical detail after a human-readable prefix:
message: `Could not save recording: ${extractErrorMessage(cause)}`
message: `POST /api/recordings 500: ${extractErrorMessage(cause)}`
Patterns
1. Simple variant: no input, static message
export const RecorderError = defineErrors({
AlreadyRecording: () => ({
message: 'A recording is already in progress',
}),
});
export type RecorderError = InferErrors<typeof RecorderError>;
return RecorderError.AlreadyRecording();
2. Variant with structured fields: message computed from input
export const DbError = defineErrors({
NotFound: ({ table, id }: { table: string; id: string }) => ({
message: `${table} '${id}' not found`,
table,
id,
}),
});
export type DbError = InferErrors<typeof DbError>;
return DbError.NotFound({ table: 'users', id: '123' });
3. Variant with cause: extractErrorMessage inside the factory
import { extractErrorMessage } from 'wellcrafted/error';
export const FfmpegError = defineErrors({
CompressFailed: ({ cause }: { cause: unknown }) => ({
message: `Failed to compress audio: ${extractErrorMessage(cause)}`,
cause,
}),
VerifyFailed: ({ cause }: { cause: unknown }) => ({
message: `Failed to verify temp file: ${extractErrorMessage(cause)}`,
cause,
}),
});
export type FfmpegError = InferErrors<typeof FfmpegError>;
function compressionFailure(cause: unknown) {
return FfmpegError.CompressFailed({ cause });
}
4. Multiple variants in one object: discriminated union built-in
export const DeviceStreamError = defineErrors({
PermissionDenied: ({ cause }: { cause: unknown }) => ({
message: `Microphone permission denied. ${extractErrorMessage(cause)}`,
cause,
}),
DeviceConnectionFailed: ({
deviceId,
cause,
}: {
deviceId: string;
cause: unknown;
}) => ({
message: `Unable to connect to device '${deviceId}'. ${extractErrorMessage(cause)}`,
deviceId,
cause,
}),
NoDevicesFound: () => ({
message: "No microphones found. Check your connections and try again.",
}),
});
export type DeviceStreamError = InferErrors<typeof DeviceStreamError>;
type NoDevicesFoundError = InferError<typeof DeviceStreamError.NoDevicesFound>;
5. Domain errors with specific operation failures
export const FsError = defineErrors({
ReadFailed: ({ path, cause }: { path: string; cause: unknown }) => ({
message: `Failed to read '${path}': ${extractErrorMessage(cause)}`,
path,
cause,
}),
WriteFailed: ({ path, cause }: { path: string; cause: unknown }) => ({
message: `Failed to write '${path}': ${extractErrorMessage(cause)}`,
path,
cause,
}),
DeleteFailed: ({ path, cause }: { path: string; cause: unknown }) => ({
message: `Failed to delete '${path}': ${extractErrorMessage(cause)}`,
path,
cause,
}),
});
export type FsError = InferErrors<typeof FsError>;
return FsError.ReadFailed({ path: '/tmp/foo.txt', cause: error });
Type Extraction
type HttpError = InferErrors<typeof HttpError>;
type ConnectionError = InferError<typeof HttpError.Connection>;
Consuming a Variant Union: Exhaustive switch, Not if/else
defineErrors builds a discriminated union tagged by name. When you translate the whole union (every variant mapped to another error, an exit code, a UI state), discriminate with an exhaustive switch (error.name) and pin it with default: error satisfies never. Do not use an if/else chain.
if (result.error.name === 'RecipientOffline') {
return RunError.PeerNotFound({ peerTarget, waitMs, syncStatus });
}
return RunError.RemoteCallFailed({ cause: result.error, peerTarget, syncStatus });
switch (result.error.name) {
case 'RecipientOffline':
return RunError.PeerNotFound({ peerTarget, waitMs, syncStatus });
case 'ActionNotFound':
case 'ActionFailed':
case 'Cancelled':
case 'NetworkFailed':
return RunError.RemoteCallFailed({ cause: result.error, peerTarget, syncStatus });
default:
return result.error satisfies never;
}
Why: error unions grow. The satisfies never default makes the producer adding a variant break the consumer's build, forcing a deliberate decision instead of a silent fall-through into the catch-all branch. Collapsing several variants into one output is fine: list their case labels explicitly so the collapse is visible and intentional.
Not every error.name === 'X' is wrong
Two shapes are legitimate and should stay as an if or a plain expression:
- Predicate: one boolean about one variant.
get isCreditsExhausted() {
return chat.error instanceof AiChatHttpError
&& chat.error.detail.name === 'InsufficientCredits';
}
- Guard: special-case one variant, then let the rest flow through a
shared path.
if (result.error.name === 'Throttled') {
await waitFor(result.error.retryAfterMs);
return retry();
}
The smell is specifically the total fold: every branch consumes the union into a different output, with no compiler pin. If you are translating the whole union, switch on it. This is not error-specific: the same rule applies to any closed discriminated union (state enums keyed by kind, phase, or state). See code-audit category 8 for the detection grep recipe.
Avoid Parallel Error Envelopes
When an internal function or a boundary that explicitly adopts Wellcrafted
needs to signal success or failure, do not invent a parallel
{ ok: true, data } | { ok: false, error } shape. This codebase already uses
Result<T, E> ({ data: T, error: null } | { data: null, error: E }). External
protocols keep their own established envelopes.
type CallResult<T> =
| { ok: true; data: T }
| { ok: false; error: { name: string; message: string } };
import type { Result } from 'wellcrafted/result';
import { defineErrors, type InferErrors } from 'wellcrafted/error';
export const CallError = defineErrors({
Timeout: ({ timeoutMs }: { timeoutMs: number }) => ({
message: `timed out after ${timeoutMs}ms`,
timeoutMs,
}),
});
export type CallError = InferErrors<typeof CallError>;
type CallResult<T> = Result<T, CallError>;
Why: Result consumers such as isOk, isErr, unwrap, tapErr, and the
wellcrafted/query adapters expect { data, error }. An ad hoc { ok }
return cannot use them and forces every consumer to learn another shape.
trySync and tryAsync are exception adapters for plain values; do not wrap an
existing Result with them. See error-handling for adaptation boundaries.
Wire-format boundary: an internal RPC or IPC surface that explicitly adopts
Wellcrafted Result can serialize { data, error } and the tagged
{ name, message, ...fields } error directly. That does not make this the
universal HTTP envelope. External protocols and existing routes keep their own
wire contracts; see error-handling/references/http-boundaries.md.
Whispering RPC Boundary
In Whispering, $lib/rpc preserves tagged errors. Do not convert service or operation errors into { title, description } or another user-facing wrapper inside an RPC adapter. UI and operation code choose display copy with $lib/report, usually report.error({ cause: error }).
Define an RPC-local defineErrors namespace only when the adapter itself owns a failure that no lower layer can own, such as a missing state lookup before calling an operation.
State machines are not Results: discriminated unions like { state: 'in-use' | 'orphan' | 'clean' } for a startup gate, or { outcome: 'graceful' | 'sigterm' } for a shutdown, are genuine state enums and should stay as discriminated unions. The smell is errors dressed as { ok } flags, not state enums.
Reserved field name: name
name is reserved at the type level: TypeScript errors if you return it from a factory, because the factory stamps it from the variant key.
defineErrors({
Bad: () => ({ message: 'x', name: 'override' }),
});
defineErrors({
Good: ({ path, payload }: { path: string; payload: unknown }) => ({
message: `failed at ${path}`,
path,
payload,
}),
});
Soft convention: avoid data as a field name
Err<E> carries a data: null at the wrapper level (it's how the shape distinguishes Err from Ok). A variant body with its own data field is visually confusing: err.data (the wrapper's null) shadows err.error.data (your field) in every reader's head.
This is not type-enforced. An earlier Wellcrafted change tried to reserve data and was reverted because the logger's "name" in err discriminator does not depend on that reservation. Prefer payload, body, value, or a domain-specific name like path, response, or input.
Non-Null Variant Errors
A defineErrors variant factory returns Err(...) around a non-null tagged
object. That makes the variant safe for Wellcrafted's error !== null
discriminator. Choosing whether a caught value becomes that variant, a
fallback, a selective rethrow, or an exception-based framework response belongs
to error-handling.