Agent Skills: defineErrors

defineErrors from wellcrafted: variant factories, extractErrorMessage, InferErrors/InferError, call site patterns. Use when creating error types or reviewing error patterns.

UncategorizedID: EpicenterHQ/epicenter/define-errors

Repository

EpicenterHQLicense: NOASSERTION
4,678359

Install this agent skill to your local

pnpm dlx add-skill https://github.com/EpicenterHQ/epicenter/tree/HEAD/.agents/skills/define-errors

Skill Files

Browse the full folder contents for define-errors.

Download Skill

Loading file tree…

.agents/skills/define-errors/SKILL.md

Skill Metadata

Name
define-errors
Description
'defineErrors from wellcrafted: variant factories, extractErrorMessage, InferErrors/InferError, call site patterns. Use when creating error types or reviewing error patterns.'

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

  1. 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
  2. The factory function returns { message, ...fields }: that is the entire API; no .withMessage(), .withContext(), or .withCause() chains
  3. cause: unknown is just a field like any other: accept it in the input and forward it in the return object
  4. When a factory accepts a cause, call extractErrorMessage(cause) inside the factory so call sites pass the raw value
  5. Each call like MyError.Variant({ ... }) returns Err(...) automatically: no separate FooErr pair
  6. Shadow the const with a same-name type using InferErrors: const FooError / type FooError
  7. Use InferError<typeof FooError.Variant> to extract a single variant's type when needed
  8. 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
  9. 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
  10. 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:
// 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)}`

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

// Call site
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>;

// Call site
return DbError.NotFound({ table: 'users', id: '123' });
// error.message -> "users '123' not found"
// error.table   -> "users"
// error.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>;

// Call sites pass the raw cause; the factory owns message construction.
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>;
// DeviceStreamError is automatically the union of all three variants

// Extracting a single variant type
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>;

// Call site
return FsError.ReadFailed({ path: '/tmp/foo.txt', cause: error });

Type Extraction

// Full union type for all variants
type HttpError = InferErrors<typeof HttpError>;

// Single variant type
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.

// ❌ 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.

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();
    }
    // every other variant flows to the shared handling below
    

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.

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

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.

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

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.