defineErrors from wellcrafted: variant factories, extractErrorMessage, InferErrors/InferError, call site patterns. Use when creating error types or reviewing error patterns.
70
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Related Skills: See
error-handlingfor trySync/tryAsync usage and toast-on-error patterns. Seeservices-layerfor 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 {
defineErrors,
extractErrorMessage,
type InferErrors,
type InferError,
} from 'wellcrafted/error';defineErrors call. Separate contracts, such as KV read and write failures, may each have their own namespace, even when each has one variant{ message, ...fields }: that is the entire API; no .withMessage(), .withContext(), or .withCause() chainscause: unknown is just a field like any other: accept it in the input and forward it in the return objectextractErrorMessage(cause) inside the factory so call sites pass the raw valueMyError.Variant({ ... }) returns Err(...) automatically: no separate FooErr pairInferErrors: const FooError / type FooErrorInferError<typeof FooError.Variant> to extract a single variant's type when neededFailed variant is acceptable when the contract exposes one deliberately undifferentiated failure.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:// Good: human-readable prefix, technical detail after
message: `Could not save recording: ${extractErrorMessage(cause)}`
// Bad: raw technical output as the entire message
message: `POST /api/recordings 500: ${extractErrorMessage(cause)}`export const RecorderError = defineErrors({
AlreadyRecording: () => ({
message: 'A recording is already in progress',
}),
});
export type RecorderError = InferErrors<typeof RecorderError>;
// Call site
return RecorderError.AlreadyRecording();export const DbError = defineErrors({
NotFound: ({ table, id }: { table: string; id: string }) => ({
message: `${table} '${id}' not found`,
table,
id,
}),
});
export type DbError = InferErrors<typeof DbError>;
// Call site
return DbError.NotFound({ table: 'users', id: '123' });
// error.message -> "users '123' not found"
// error.table -> "users"
// error.id -> "123"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>;
// Call sites pass the raw cause; the factory owns message construction.
function compressionFailure(cause: unknown) {
return FfmpegError.CompressFailed({ cause });
}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>;
// DeviceStreamError is automatically the union of all three variants
// Extracting a single variant type
type NoDevicesFoundError = InferError<typeof DeviceStreamError.NoDevicesFound>;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>;
// Call site
return FsError.ReadFailed({ path: '/tmp/foo.txt', cause: error });// Full union type for all variants
type HttpError = InferErrors<typeof HttpError>;
// Single variant type
type ConnectionError = InferError<typeof HttpError.Connection>;switch, Not if/elsedefineErrors 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.
// ❌ non-exhaustive fold: a 6th DispatchError variant lands in `else` silently
if (result.error.name === 'RecipientOffline') {
return RunError.PeerNotFound({ peerTarget, waitMs, syncStatus });
}
return RunError.RemoteCallFailed({ cause: result.error, peerTarget, syncStatus });
// ✅ exhaustive: a 6th variant fails to compile until someone buckets it
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.
error.name === 'X' is wrongTwo shapes are legitimate and should stay as an if or a plain expression:
get isCreditsExhausted() {
return chat.error instanceof AiChatHttpError
&& chat.error.detail.name === 'InsufficientCredits';
}if (result.error.name === 'Throttled') {
await waitFor(result.error.retryAfterMs);
return retry();
}
// every other variant flows to the shared handling belowThe 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 7 for the detection grep recipe.
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.
// Wrong: parallel invention to Result<T, E>
type CallResult<T> =
| { ok: true; data: T }
| { ok: false; error: { name: string; message: string } };// Right: use Result + defineErrors
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.
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.
namename is reserved at the type level: TypeScript errors if you return it from a factory, because the factory stamps it from the variant key.
// Type error: factory would overwrite this anyway
defineErrors({
Bad: () => ({ message: 'x', name: 'override' }),
});
// ✅ Fine
defineErrors({
Good: ({ path, payload }: { path: string; payload: unknown }) => ({
message: `failed at ${path}`,
path,
payload,
}),
});data as a field nameErr<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.
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.
ecba024
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.