Error modeling
Every expected failure is a class in the typed error channel. This page answers which
constructor to use, how to name the tag, and what fields an error carries, including
message, cause and isRetryable.
Use Schema.TaggedError by default
Section titled “Use Schema.TaggedError by default”Impact: HIGH an error that cannot encode cannot reach a client
Schema.TaggedError<Self>()(tag, fields)has Schema fields, so the error encodes and decodes across an HTTP or RPC boundary.Data.TaggedErrorhas plain fields and cannot.- The question is not taste: it is whether the error crosses a process boundary. One kind of error everywhere beats two kinds plus a translation layer.
Data.TaggedErrorstays correct for a local CLI or engine whose errors stay in one process.- Beta-era app repos call this
Schema.TaggedErrorClass. Read them for the pattern, useSchema.TaggedError.
❌ Incorrect — a plain error that cannot be encoded:
export class RuleNotFound extends Data.TaggedError("RuleNotFound")<{ readonly ruleId: string}> {}✅ Correct — schema-backed, namespaced, branded:
export class RuleNotFound extends Schema.TaggedError<RuleNotFound>()( "@app/labeling/RuleNotFound", { ruleId: RuleId }, // branded domain schema) { override get message(): string { return `Labeling rule ${this.ruleId} not found` }}Source: notes/06-errors/error-modeling.md · Decision 1
Namespace every tag as @app/<folder>/<Name>
Section titled “Namespace every tag as @app/<folder>/<Name>”Impact: MEDIUM prevents silent tag collisions
- The tag is a global key, and nothing checks for collisions at compile time.
NotFoundErroris the single most likely name to be chosen twice. - Same reasoning as service tag ids in service definition.
- Untagged
Schema.Error/Data.Errorare out: without_tagthere is nothing forcatchTagto match.
❌ Incorrect — a bare tag:
export class NotFoundError extends Schema.TaggedError<NotFoundError>()( "NotFoundError", { id: Schema.String },) {}✅ Correct — namespaced by feature folder:
export class RuleNotFound extends Schema.TaggedError<RuleNotFound>()( "@app/labeling/RuleNotFound", { ruleId: RuleId },) {}Source: notes/06-errors/error-modeling.md · Decision 2
Derive message with a getter; never store it
Section titled “Derive message with a getter; never store it”Impact: LOW the sentence cannot go stale
- Structured fields are what code matches on.
messageis derived from them for humans. - A stored
messageduplicates the fields, and the copy goes stale the moment someone updates a field without updating the sentence.
❌ Incorrect — message as a field:
export class RuleNotFound extends Schema.TaggedError<RuleNotFound>()( "@app/labeling/RuleNotFound", { ruleId: RuleId, message: Schema.String },) {}✅ Correct — computed from the fields:
export class RuleNotFound extends Schema.TaggedError<RuleNotFound>()( "@app/labeling/RuleNotFound", { ruleId: RuleId },) { override get message(): string { return `Labeling rule ${this.ruleId} not found` }}Source: notes/06-errors/error-modeling.md · Decision 3
Brand valid ids; keep rejected input as a raw* string
Section titled “Brand valid ids; keep rejected input as a raw* string”Impact: MEDIUM a brand on rejected input is a lie
- An id that passed validation uses its branded domain schema (
RuleId). - Input that failed decoding is named
raw*and typedSchema.String. Storing it as the brand would claim the very validation it failed. Never cast.
❌ Incorrect — the rejected value cast into the brand:
export class RuleIdInvalid extends Schema.TaggedError<RuleIdInvalid>()( "@app/labeling/RuleIdInvalid", { ruleId: RuleId },) {}new RuleIdInvalid({ ruleId: input as RuleId })✅ Correct — a plainly named raw string:
export class RuleIdInvalid extends Schema.TaggedError<RuleIdInvalid>()( "@app/labeling/RuleIdInvalid", { rawRuleId: Schema.String },) {}Source: notes/06-errors/error-modeling.md · Decision 4
Put a required cause only on an error that wraps one
Section titled “Put a required cause only on an error that wraps one”Impact: MEDIUM a wrapper never loses what it wraps
- An error that wraps another failure carries
cause: Schema.Defect(), required.PersistenceError(see SQL and transactions) is the standing example.optionalwould only let a constructor drop it. - An error that wraps nothing (
RuleNotFound) has nocausefield at all. - Contract errors never carry a
cause(see error boundaries). Failures stay in the typed channel; defects go tocatchDefector an unexpected-error envelope.
❌ Incorrect — cause on everything, and optional where it matters:
export class RuleNotFound extends Schema.TaggedError<RuleNotFound>()( "@app/labeling/RuleNotFound", { ruleId: RuleId, cause: Schema.optional(Schema.Defect()) }, // wraps nothing) {}✅ Correct — required on the wrapper, absent elsewhere:
export class PersistenceError extends Schema.TaggedError<PersistenceError>()( "@app/db/PersistenceError", { cause: Schema.Defect() },) {}Source: notes/06-errors/error-modeling.md · Decision 4, amended
Expose isRetryable as a getter on upstream errors
Section titled “Expose isRetryable as a getter on upstream errors”Impact: LOW callers classify without matching every field
- An upstream error class that a caller must classify exposes an
isRetryablegetter, asSqlErrorandAiErrordo. It is computed from the fields, likemessage, not a field in the union. - It answers “could a later attempt succeed?”, never “is a replay safe?”. Replay safety is the caller’s judgement (see scheduling and retry).
❌ Incorrect — retryability stored as data:
{ status: Schema.Number, retryable: Schema.Boolean }✅ Correct — derived from the fields:
export class GitHubRequestFailed extends Schema.TaggedError<GitHubRequestFailed>()( "@app/github/GitHubRequestFailed", { status: Schema.Number, cause: Schema.Defect() },) { get isRetryable(): boolean { return this.status === 429 || this.status >= 500 }}Source: notes/06-errors/error-modeling.md · Decision 4, amended