← Concepts & practices
Choice Errors, results, and recovery

Kinds and sentinels

What should the caller recognize?

“Something went wrong” is enough to log a failure. It isn’t enough to decide what happens next. Let’s give a caller a real decision, then choose how that information reaches it.

The judgment to keep

Choose an error representation for the recovery it must support, the boundary it must cross, and the changes it must survive.

TypeScriptGo One caller. Four possible contracts.
Start with the caller

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?

Our shared scenarioLoad saved note #42

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.

Put the alternatives on the same table

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.

A

Message matching

Does the text contain this phrase?

Read the human explanation and use it to recognize the failure.

Useful when an outside system gives you only text. Your adapter owns its wording assumptions.
B

Sentinel matching

Is this the known marker?

Give a category a shared error value that callers can recognize.

A small in-process contract. The bare marker carries no per-occurrence data.
C

A kind on an error

Which category does this error name?

Keep a stable category alongside a human message and optional context.

A uniform error container is convenient. Constructors or validation must enforce the data each kind needs.
D

Per-case shapes

Which case, and what comes with it?

Give missing and unavailable their own required data.

Useful when recovery needs different fields. Exhaustiveness depends on the language and handler.
What does each contract ask you to maintain?
RepresentationCaller depends onPer-occurrence dataAs the contract changes
Message matchingThe 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 matchingA 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 errorA 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 shapesA 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.

Keep the operation fixed

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?

A controlled comparison

Change what the contract has to survive.

Runs TypeScript in your browser
ProducerService unavailable
Between producer and callerKeep the original contract
CallerChoose a recovery action

The original message and machine-readable information reach the caller.

A / Message matchingOffer a retry

Retry delay: not carried

B / Sentinel matchingOffer a retry

Retry delay: not carried

C / A kind on an errorOffer a retry

Retry delay: 0s

D / Per-case shapesOffer a retry

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.

Separate the two comparisons

Hold the representation steady. Change the language.

Read the caller in your languages.

The source below follows these language choices. The live comparison above always runs TypeScript.

Is this the known marker?

TypeScriptSentinel matching
failures.ts
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();
}
GoSentinel matching
failures.go
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()
	}
}
TypeScript

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.

Union narrowing and exhaustiveness ↗Error.cause ↗
Go

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.

errors.Is and errors.As ↗
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.

TypeScriptProducer and wrapper
failures.ts
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;
}
GoProducer and wrapper
failures.go
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.

TypeScriptComplete example
failures.ts
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());
GoComplete example
failures.go
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.

Let the set grow

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
evolution.ts
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.

A production boundary

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.

Go service → JSON → TypeScript caller

Let the frontend meet an unfamiliar response.

The decoder runs here. The Go producer is shown below; this page makes no network request.

Caller decision Offer a retry after 30s

Known version, known code, valid case data. The decoder creates a local value the typed handler can use.

Untrusted JSONValidate version + code + dataLocal case: unavailable

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.

failures.go · wire excerpt
// 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.

wire.ts
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.

Make a conditional recommendation

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

A small in-process Go contract

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.

Different cases need different data

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.

Independently deployed service and UI

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.

A dependency exposes only text

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.

One known condition inside a Go package.

Callers only need to recognize “no saved note”. No per-occurrence data, no network boundary. Which starting point fits?

The Go service rolls out first.

It can now return access_denied. The older TypeScript frontend only knows missing and unavailable. What should that reader do?

Unavailable now needs a delay.

Two requests may fail with different retry delays. Both currently share the same sentinel. Where should the delay live?

Practice feedback stays on this page; it is not saved.
Leave yourself a useful note

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 →