Skip to content

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.

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.TaggedError has 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.TaggedError stays 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, use Schema.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. NotFoundError is the single most likely name to be chosen twice.
  • Same reasoning as service tag ids in service definition.
  • Untagged Schema.Error / Data.Error are out: without _tag there is nothing for catchTag to 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. message is derived from them for humans.
  • A stored message duplicates 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 typed Schema.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. optional would only let a constructor drop it.
  • An error that wraps nothing (RuleNotFound) has no cause field at all.
  • Contract errors never carry a cause (see error boundaries). Failures stay in the typed channel; defects go to catchDefect or 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 isRetryable getter, as SqlError and AiError do. It is computed from the fields, like message, 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