Same operation. Different next steps.
You open a saved note. Sometimes the note no longer exists. Sometimes the service is temporarily unavailable. Those can look like the same red banner, but they ask the product to do different things.
If you’ve ever branched on an API error code, checked errors.Is, or matched an
enum variant, you’ve already made part of this choice. The useful question is: what information did that branch trust?
A read operation. We’re deciding what to show, not automatically retrying a request.
- Note is missing
- Show an empty state.
- Service is unavailable
- Offer a retry; use a supplied delay when available.
- Failure is unrecognized
- Keep a generic error state. Don’t pretend the note is missing.
The message helps a person understand what happened. The category helps a caller choose a branch. Per-occurrence data helps that branch do its job. You may keep all three together, but they have different responsibilities.
Four ways to carry the answer.
These are useful positions to compare, not four sealed boxes. A typed wrapper can carry data around a sentinel. A tagged error can use constructors that enforce each case. Start with the responsibilities; combine the mechanisms when you need to.
Message matching
Does the text contain this phrase?
Read the human explanation and use it to recognize the failure.
Sentinel matching
Is this the known marker?
Give a category a shared error value that callers can recognize.
A kind on an error
Which category does this error name?
Keep a stable category alongside a human message and optional context.
Per-case shapes
Which case, and what comes with it?
Give missing and unavailable their own required data.
| Representation | Caller depends on | Per-occurrence data | As the contract changes |
|---|---|---|---|
| Message matching | The phrase and the parser’s assumptions. | Parse text or add a structured channel. | Wording can change behavior. Isolate vendor-specific parsing in an adapter. |
| Sentinel matching | A known value or the language’s matching protocol. | A bare marker has none; a wrapper can add it. | Keep the marker stable. The exported markers do not enumerate every possible error. |
| Kind on an error | A stable tag plus a recognized container. | Common fields; validate which a kind requires. | Specify unknown-kind behavior and decide whether the tag set is open or closed. |
| Per-case shapes | A recognized case with its required fields. | The shape expresses the data for that case. | A closed type and exhaustive handler can expose missing branches at compile time. |
A sentinel is a distinguished value used as a recognizable marker. A kind is a category carried by a value. A kind can be a string or an enum; choosing a tag does not automatically define a network format.
Now move the constraint.
First, all four callers recognize the failure. Then rewrite the message. Add a wrapper. Ask for recovery data. This is where the choice becomes interesting: which part of the contract did you just put pressure on?
Change what the contract has to survive.
The original message and machine-readable information reach the caller.
Retry delay: not carried
Retry delay: not carried
Retry delay: 0s
Retry delay: 0s
All four recognize the category in this controlled example. Start here: the decision is about what the contract must survive next.
This runs the shipped classifiers against one error-cause chain. A retry result describes a UI action; it does not send a request or schedule a retry. Go is a source comparison, checked separately with its native tools.
Why does wrapping break the phrase matcher here?A deliberate wrapper contract
The wrapper’s display text is only load note failed. The original error
remains in its cause. Our phrase matcher reads the outer text; the other classifiers
inspect the cause chain.
A wrapper that appends the original message could keep this particular parser working. A parser could also walk every cause and search its text. Both still depend on the wording. Conversely, a classifier that ignores causes can lose a perfectly preserved typed error. Representation and traversal must work together.
The examples contain one relevant domain failure in a finite chain. Aggregated failures, conflicting matches, and cross-realm JavaScript objects need an explicit policy beyond this comparison.
Hold the representation steady. Change the language.
The source below follows these language choices. The live comparison above always runs TypeScript.
Is this the known marker?
export const Missing = Object.freeze(new Error('missing marker'));
export const Unavailable = Object.freeze(new Error('unavailable marker'));
export function bySentinel(error: unknown): Decision {
for (const cause of causes(error)) {
if (cause === Missing) return missing();
if (cause === Unavailable) return retry();
}
return fallback();
} var ErrMissing = errors.New("missing marker")
var ErrUnavailable = errors.New("unavailable marker")
func BySentinel(err error) Decision {
switch {
case errors.Is(err, ErrMissing):
return missing()
case errors.Is(err, ErrUnavailable):
return retry(nil)
default:
return fallback()
}
} Sentinels use object identity; the helper walks Error.cause. instanceof assumes these errors come from the same realm and class definitions. The union handler’s never check makes a missed case a type error. A cast from JSON cannot supply
that guarantee.
errors.Is follows wrapping and supports custom matching, beyond direct
equality. errors.As finds an assignable error type through the chain. Our per-case
types carry different data, but Go does not check that this classifier handles every error
type.
See what the producer constructsSame changes, same caller decision
The wrapper intentionally keeps its display message separate from its cause. A retry delay of 0 means no requested wait in this example; it is different from a missing delay.
export function makeFailure(representation: Representation, kind: Kind, change: Change): Error {
const message =
change === 'wording'
? kind === 'missing'
? 'This note is gone'
: 'Please try again later'
: kind === 'missing'
? 'note not found'
: 'service unavailable';
const seconds = change === 'payload' ? 30 : 0;
let error: Error;
switch (representation) {
case 'message':
error = new Error(message);
break;
case 'sentinel':
error = new Error(message, { cause: kind === 'missing' ? Missing : Unavailable });
break;
case 'tag':
error = new TaggedFailure(kind, message, kind === 'unavailable' ? seconds : undefined);
break;
case 'cases':
error = new CaseFailure(
kind === 'missing' ? { kind, noteId: 42 } : { kind, retryAfterSeconds: seconds },
message
);
break;
}
if (change === 'wrap') return new Error('load note failed', { cause: error });
if (change === 'lost-cause') return new Error('load note failed');
return error;
} func MakeFailure(representation, kind, change string) error {
message := "note not found"
if kind == "unavailable" {
message = "service unavailable"
}
if change == "wording" {
message = "This note is gone"
if kind == "unavailable" {
message = "Please try again later"
}
}
seconds := 0
if change == "payload" {
seconds = 30
}
var err error
switch representation {
case "message":
err = errors.New(message)
case "sentinel":
marker := ErrMissing
if kind == "unavailable" {
marker = ErrUnavailable
}
err = &ContextError{message, marker}
case "tag":
var delay *int
if kind == "unavailable" {
delay = &seconds
}
err = &TaggedFailure{Kind(kind), message, delay}
case "cases":
if kind == "missing" {
err = &MissingFailure{42, message}
} else {
err = &UnavailableFailure{seconds, message}
}
default:
panic("unknown representation")
}
if change == "wrap" {
return &ContextError{"load note failed", err}
}
if change == "lost-cause" {
return errors.New("load note failed")
}
return err
} Copy the complete examplesStandard library only
The snippets above use helpers and imports from these complete files. Copy one file, then run its command with a locally installed toolchain. Each prints the four decisions for an unavailable failure with a 30-second delay.
export type Kind = 'missing' | 'unavailable';
export type Representation = 'message' | 'sentinel' | 'tag' | 'cases';
export type Change = 'baseline' | 'wording' | 'wrap' | 'payload' | 'lost-cause';
export type Decision = {
action: 'missing' | 'retry' | 'fallback';
retryAfterSeconds: number | null;
};
export const representations: Representation[] = ['message', 'sentinel', 'tag', 'cases'];
const fallback = (): Decision => ({ action: 'fallback', retryAfterSeconds: null });
const missing = (): Decision => ({ action: 'missing', retryAfterSeconds: null });
const retry = (seconds: number | null = null): Decision => ({
action: 'retry',
retryAfterSeconds: seconds
});
// One same-realm Error.cause chain; cycles stop instead of hanging the demo.
export function* causes(error: unknown): Generator<Error> {
const seen = new Set<Error>();
while (error instanceof Error && !seen.has(error)) {
seen.add(error);
yield error;
error = error.cause;
}
}
export function byMessage(error: unknown): Decision {
const message = error instanceof Error ? error.message : '';
if (message.includes('note not found')) return missing();
if (message.includes('service unavailable')) return retry();
return fallback();
}
export const Missing = Object.freeze(new Error('missing marker'));
export const Unavailable = Object.freeze(new Error('unavailable marker'));
export function bySentinel(error: unknown): Decision {
for (const cause of causes(error)) {
if (cause === Missing) return missing();
if (cause === Unavailable) return retry();
}
return fallback();
}
export class TaggedFailure extends Error {
kind: Kind;
retryAfterSeconds: number | undefined;
constructor(kind: Kind, message: string, retryAfterSeconds?: number) {
super(message);
this.kind = kind;
this.retryAfterSeconds = retryAfterSeconds;
}
}
export function byTag(error: unknown): Decision {
for (const cause of causes(error)) {
if (!(cause instanceof TaggedFailure)) continue;
switch (cause.kind) {
case 'missing':
return missing();
case 'unavailable':
return retry(cause.retryAfterSeconds ?? null);
}
}
return fallback();
}
export type Failure =
{ kind: 'missing'; noteId: number } | { kind: 'unavailable'; retryAfterSeconds: number };
export function decideFailure(failure: Failure): Decision {
switch (failure.kind) {
case 'missing':
return missing();
case 'unavailable':
return retry(failure.retryAfterSeconds);
default: {
const unhandled: never = failure;
return unhandled;
}
}
}
export class CaseFailure extends Error {
failure: Failure;
constructor(failure: Failure, message: string) {
super(message);
this.failure = failure;
}
}
export function byCases(error: unknown): Decision {
for (const cause of causes(error)) {
if (cause instanceof CaseFailure) return decideFailure(cause.failure);
}
return fallback();
}
export function makeFailure(representation: Representation, kind: Kind, change: Change): Error {
const message =
change === 'wording'
? kind === 'missing'
? 'This note is gone'
: 'Please try again later'
: kind === 'missing'
? 'note not found'
: 'service unavailable';
const seconds = change === 'payload' ? 30 : 0;
let error: Error;
switch (representation) {
case 'message':
error = new Error(message);
break;
case 'sentinel':
error = new Error(message, { cause: kind === 'missing' ? Missing : Unavailable });
break;
case 'tag':
error = new TaggedFailure(kind, message, kind === 'unavailable' ? seconds : undefined);
break;
case 'cases':
error = new CaseFailure(
kind === 'missing' ? { kind, noteId: 42 } : { kind, retryAfterSeconds: seconds },
message
);
break;
}
if (change === 'wrap') return new Error('load note failed', { cause: error });
if (change === 'lost-cause') return new Error('load note failed');
return error;
}
export const classifiers = { message: byMessage, sentinel: bySentinel, tag: byTag, cases: byCases };
export function runScenario(representation: Representation, kind: Kind, change: Change) {
return classifiers[representation](makeFailure(representation, kind, change));
}
export function example() {
return representations.map((representation) => ({
representation,
...runScenario(representation, 'unavailable', 'payload')
}));
}
console.log(example());
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
type Decision struct {
Action string `json:"action"`
RetryAfterSeconds *int `json:"retryAfterSeconds"`
}
func missing() Decision { return Decision{"missing", nil} }
func retry(seconds *int) Decision { return Decision{"retry", seconds} }
func fallback() Decision { return Decision{"fallback", nil} }
// Outer display text deliberately need not include the cause's text.
type ContextError struct {
Message string
Cause error
}
func (e *ContextError) Error() string { return e.Message }
func (e *ContextError) Unwrap() error { return e.Cause }
func ByMessage(err error) Decision {
if err == nil {
return fallback()
}
if strings.Contains(err.Error(), "note not found") {
return missing()
}
if strings.Contains(err.Error(), "service unavailable") {
return retry(nil)
}
return fallback()
}
var ErrMissing = errors.New("missing marker")
var ErrUnavailable = errors.New("unavailable marker")
func BySentinel(err error) Decision {
switch {
case errors.Is(err, ErrMissing):
return missing()
case errors.Is(err, ErrUnavailable):
return retry(nil)
default:
return fallback()
}
}
type Kind string
const (
Missing Kind = "missing"
Unavailable Kind = "unavailable"
)
type TaggedFailure struct {
Kind Kind
Message string
RetryAfterSeconds *int
}
func (e *TaggedFailure) Error() string { return e.Message }
func ByTag(err error) Decision {
var failure *TaggedFailure
if !errors.As(err, &failure) || failure == nil {
return fallback()
}
switch failure.Kind {
case Missing:
return missing()
case Unavailable:
return retry(failure.RetryAfterSeconds)
default:
return fallback()
}
}
type MissingFailure struct {
NoteID int
Message string
}
func (e *MissingFailure) Error() string { return e.Message }
type UnavailableFailure struct {
RetryAfterSeconds int
Message string
}
func (e *UnavailableFailure) Error() string { return e.Message }
func ByCases(err error) Decision {
var absent *MissingFailure
if errors.As(err, &absent) && absent != nil {
return missing()
}
var unavailable *UnavailableFailure
if errors.As(err, &unavailable) && unavailable != nil {
return retry(&unavailable.RetryAfterSeconds)
}
// Concrete case types carry their data. Go does not check exhaustiveness here.
return fallback()
}
func MakeFailure(representation, kind, change string) error {
message := "note not found"
if kind == "unavailable" {
message = "service unavailable"
}
if change == "wording" {
message = "This note is gone"
if kind == "unavailable" {
message = "Please try again later"
}
}
seconds := 0
if change == "payload" {
seconds = 30
}
var err error
switch representation {
case "message":
err = errors.New(message)
case "sentinel":
marker := ErrMissing
if kind == "unavailable" {
marker = ErrUnavailable
}
err = &ContextError{message, marker}
case "tag":
var delay *int
if kind == "unavailable" {
delay = &seconds
}
err = &TaggedFailure{Kind(kind), message, delay}
case "cases":
if kind == "missing" {
err = &MissingFailure{42, message}
} else {
err = &UnavailableFailure{seconds, message}
}
default:
panic("unknown representation")
}
if change == "wrap" {
return &ContextError{"load note failed", err}
}
if change == "lost-cause" {
return errors.New("load note failed")
}
return err
}
func RunScenario(representation, kind, change string) Decision {
err := MakeFailure(representation, kind, change)
switch representation {
case "message":
return ByMessage(err)
case "sentinel":
return BySentinel(err)
case "tag":
return ByTag(err)
case "cases":
return ByCases(err)
default:
panic("unknown representation")
}
}
// A deliberate public schema; do not JSON-encode an arbitrary error object.
type WireFailure struct {
Version int `json:"version"`
Code string `json:"code"`
NoteID *int `json:"noteId,omitempty"`
RetryAfterSeconds *int `json:"retryAfterSeconds,omitempty"`
}
func EncodeUnavailable(seconds int) ([]byte, error) {
if seconds < 0 || seconds > 3600 {
return nil, errors.New("retry hint out of range")
}
return json.Marshal(WireFailure{Version: 1, Code: "temporarily_unavailable", RetryAfterSeconds: &seconds})
}
func main() {
for _, representation := range []string{"message", "sentinel", "tag", "cases"} {
result, err := json.Marshal(RunScenario(representation, "unavailable", "payload"))
if err != nil {
panic(err)
}
fmt.Println(representation, string(result))
}
encoded, err := EncodeUnavailable(30)
if err != nil {
panic(err)
}
fmt.Println("wire", string(encoded))
}
TypeScript · Node 22.18+node --experimental-strip-types failures.ts
Gogo run failures.go
No packages are required for these files. The Go executable also prints a JSON response used in the boundary example below.
What if a third failure arrives?
Suppose “access denied” joins missing and unavailable. It’s tempting to say a closed set makes callers safe. Be more precise: a particular type definition and handler can make an omitted branch fail compilation.
In TypeScript, the never assignment below rejects an unhandled union member. Add
the new case and the handler needs attention. A wildcard can deliberately keep future cases on
a fallback path instead.
Try adding a case locallyCompiler exercise · TypeScript
type Failure = { kind: 'missing' } | { kind: 'unavailable' };
// Add: | { kind: 'access_denied' }
function decide(failure: Failure): string {
switch (failure.kind) {
case 'missing': return 'show empty state';
case 'unavailable': return 'offer retry';
default: {
const unhandled: never = failure;
return unhandled;
}
}
}
Add | { kind: 'access_denied' } to the union. Type-check with tsc --strict --noEmit evolution.ts using an installed TypeScript compiler.
These are compile-time exercises, not browser simulations. The repository check compiles the originals, inserts the extra case, and verifies that each changed handler fails for the expected exhaustiveness error.
Your Go service and TypeScript frontend don’t share an error object.
Inside the service, a sentinel or a typed error might be exactly what you want. At the API boundary, map it to a public response. The frontend receives bytes, not the same pointer, class instance, or enum value.
Give those bytes an explicit contract: a version, a stable code, and the data that code needs. Decode them before calling the typed handler. That lets each side use an appropriate local representation without asking the UI to understand a database driver’s errors.
Let the frontend meet an unfamiliar response.
The decoder runs here. The Go producer is shown below; this page makes no network request.
Known version, known code, valid case data. The decoder creates a local value the typed handler can use.
This small contract accepts extra fields, requires a safe nonnegative note ID, and bounds integer retry delays to 0–3,600 seconds. Unknown versions, codes, and invalid data take the fallback. Those are deliberate API rules, not universal error limits.
Inspect the wire contractGo producer + TypeScript decoder
A Go boundary encodes one public case.
// A deliberate public schema; do not JSON-encode an arbitrary error object.
type WireFailure struct {
Version int `json:"version"`
Code string `json:"code"`
NoteID *int `json:"noteId,omitempty"`
RetryAfterSeconds *int `json:"retryAfterSeconds,omitempty"`
}
func EncodeUnavailable(seconds int) ([]byte, error) {
if seconds < 0 || seconds > 3600 {
return nil, errors.New("retry hint out of range")
}
return json.Marshal(WireFailure{Version: 1, Code: "temporarily_unavailable", RetryAfterSeconds: &seconds})
} Excerpt from the complete Go file above. The boundary chooses a public code and validates the delay before encoding. Mapping internal errors to this case is an application decision.
The TypeScript boundary validates before narrowing.
import { decideFailure, type Failure } from './failures.ts';
export type DecodeResult = { ok: true; failure: Failure } | { ok: false; reason: string };
export function decodeFailure(json: string): DecodeResult {
let value: unknown;
try {
value = JSON.parse(json);
} catch {
return { ok: false, reason: 'Invalid JSON' };
}
if (typeof value !== 'object' || value === null || Array.isArray(value))
return { ok: false, reason: 'Expected an object' };
if (!('version' in value) || value.version !== 1)
return { ok: false, reason: 'Unsupported envelope version' };
if (!('code' in value)) return { ok: false, reason: 'Missing failure code' };
if (
value.code === 'note_missing' &&
'noteId' in value &&
typeof value.noteId === 'number' &&
Number.isSafeInteger(value.noteId) &&
value.noteId >= 0
) {
return { ok: true, failure: { kind: 'missing', noteId: value.noteId } };
}
if (
value.code === 'temporarily_unavailable' &&
'retryAfterSeconds' in value &&
typeof value.retryAfterSeconds === 'number' &&
Number.isInteger(value.retryAfterSeconds) &&
value.retryAfterSeconds >= 0 &&
value.retryAfterSeconds <= 3600
) {
return {
ok: true,
failure: { kind: 'unavailable', retryAfterSeconds: value.retryAfterSeconds }
};
}
return { ok: false, reason: 'Unknown code or invalid case data' };
}
export function wireDecision(json: string) {
const decoded = decodeFailure(json);
return decoded.ok
? decideFailure(decoded.failure)
: { action: 'fallback' as const, retryAfterSeconds: null };
}
export function example() {
return wireDecision('{"version":1,"code":"temporarily_unavailable","retryAfterSeconds":30}');
}
console.log(example());
Complete wire.ts; place it beside the complete failures.ts above. Run node --experimental-strip-types wire.ts. The imported handler
receives only a decoded case.
This is an authored application contract, not a universal error-envelope standard. HTTP status, authentication, cancellation, observability, and retry scheduling still need their own policies. Avoid publishing private error chains as an API response.
Build UIs?See where this shows up in your components.
A frontend recovery state is a product decision.
A missing saved note can render an empty state with a way back to the catalog. A temporary failure can keep the last successful note visible with a retry affordance. A response your version doesn’t recognize should keep an error state, not quietly become “no note”.
Map the decoded domain case into a view state once, near the request boundary. The component can render that state without repeatedly parsing exception text. Choose user-facing copy independently: rewording a banner or translating it should not change which branch runs.
The delay is a hint for the retry UI. This example performs no automatic retry. Actual retry policy depends on the operation, cancellation, and your service’s contract.
“It depends” should end with a decision.
We can name the dependency. Who owns the producer? What does recovery need? Does the failure cross a deployment boundary? Those answers are enough to choose a starting point and say what would make us change it.
Start with a sentinel when recognition is enough.
Expose it intentionally, preserve wrapping, and use errors.Is. Revisit the
design when callers need data tied to an occurrence.
Give the data an explicit shape.
Use a union or enum where it fits the language, or typed Go errors. A tagged container with checked construction is also reasonable. Choose how new cases reach existing callers.
Define and decode a public error contract.
Keep internal representations local. Add an unknown-response path for older clients. Stable codes help only when their meaning and payload rules remain stable too.
Contain the message parser at an adapter.
Test real response fixtures, keep an unknown fallback, and translate recognized text into your own contract. Replace the parser when a structured API becomes available.
One subtle production decision is what you preserve through wrapping. In Go, %w exposes a wrapped error for inspection. That can become something callers depend on; choose what
your package promises to expose. Go’s error-wrapping guidance discusses that API
commitment. A human-readable explanation and an inspectable cause are separate channels.
Keep the reason for the choice.
After you make this decision in a real feature, leave a note the next person can use. Include the constraint that would make you reconsider; “we use sentinels” is much less useful than why you chose them here.
- Why
- The caller must distinguish a missing note from a temporary failure.
- What
- Go uses local errors; the API maps known failures to stable public codes and case data. TypeScript validates the response into a local union.
- Constraint
- The frontend and service can deploy independently. Human messages may change.
- Fallback
- Unknown versions, codes, or invalid data stay generic failures.
- Reconsider when
- Another recovery case or a new payload requirement changes what callers must know.
An example decision note to adapt to your own work. Nothing here is saved to an account.
Explore more concepts & practices →