← Concepts & practices
Pattern Errors, results, and recovery

Errors across a boundary

What should survive when the error becomes JSON?

A payment service may know the provider, socket, shard, and stack that caused a failure. The browser does not need—and should not receive—that whole story. At an application boundary, translate private evidence into a public code, safe message, and traceable link. The response body is the contract the other side actually gets.

The judgment to keep

Translate where ownership changes: preserve a stable category and the fields the client can act on, keep the cause and stack on the server, and include a trace ID that joins the two views.

TypeScriptGo One payment failure · two boundary implementations
Start with the receiver

The wire does not carry your exception.

Inside a service, a payment timeout can be wrapped with useful context: which order, which provider, which attempt. Once an HTTP handler serializes it, the next process receives bytes. It does not receive the original class, stack, cause chain, or Go error identity.

There are two easy mistakes. Passing the internal sentence through makes a database address or an invariant part of the public API. Flattening every failure to { message: "Request failed" } is safe but throws away useful recovery: a missing order, a write conflict, and a temporary dependency outage do not ask the client to do the same thing.

The boundary’s job is translation, not transportation: decide what the next side may know and what action that information supports.

Read the tempting starting pointTypeScript · internal text becomes JSON
errors.ts · internal error on the wire
/** The tempting form: put the internal error sentence on the wire. */
export function canonicalResponse(error: Error): HttpResponse {
	return {
		status: 500,
		body: { message: error.message, stack: error.stack, cause: error.cause }
	};
}

This handler is not wrong because its JSON is invalid. It is wrong because it publishes fields whose meaning belongs to the private implementation. A provider rename, stack change, or new wrapper becomes a client-visible change.

Give the response a shape

One body leaks. The other one translates.

The canonical handler sends whatever error text it has. The twin classifies known failures and creates a small envelope: code for a stable branch, message for safe prose, traceId for support, and an optional retry hint only when the boundary owns its meaning.

Canonical

Serialize the internal error

Convenient at first; the driver’s vocabulary becomes the contract.

errors.ts · canonical handler
/** The tempting form: put the internal error sentence on the wire. */
export function canonicalResponse(error: Error): HttpResponse {
	return {
		status: 500,
		body: { message: error.message, stack: error.stack, cause: error.cause }
	};
}

export function canonicalHandler(error: unknown): HttpResponse {
	if (error instanceof Error) {
		return canonicalResponse(error);
	}
	return { status: 500, body: { message: String(error) } };
}
Twin

Translate to a public envelope

Known cases keep recovery data; unknown cases become safe and traceable.

errors.ts · translated envelope
const publicMessages: Record<PublicCode, string> = {
	'invalid-input': 'Check the highlighted fields.',
	'not-found': 'We could not find that order.',
	conflict: 'This order changed. Refresh and try again.',
	'dependency-unavailable': 'Payments are temporarily unavailable.',
	unknown: 'Something went wrong.'
};

function isInternalFailure(value: unknown): value is InternalFailure {
	if (typeof value !== 'object' || value === null || !('kind' in value)) return false;
	const kind = (value as { kind?: unknown }).kind;
	return (
		kind === 'invalid-input' ||
		kind === 'not-found' ||
		kind === 'conflict' ||
		kind === 'dependency-unavailable' ||
		kind === 'unknown'
	);
}

export function translate(error: unknown, traceId: string): HttpResponse {
	if (!isInternalFailure(error)) {
		return {
			status: 500,
			body: { code: 'unknown', message: publicMessages.unknown, traceId }
		};
	}

	const status =
		error.kind === 'invalid-input'
			? 400
			: error.kind === 'not-found'
				? 404
				: error.kind === 'conflict'
					? 409
					: error.kind === 'dependency-unavailable'
						? 503
						: 500;
	const body: PublicError = {
		code: error.kind,
		message: publicMessages[error.kind],
		traceId,
		...(error.kind === 'dependency-unavailable'
			? { retryAfterSeconds: error.retryAfterSeconds }
			: {})
	};
	return { status, body };
}

export function clientState(response: HttpResponse): string {
	if (typeof response.body !== 'object' || response.body === null || !('code' in response.body)) {
		return 'show generic failure';
	}
	const code = (response.body as { code?: unknown }).code;
	switch (code) {
		case 'invalid-input':
			return 'show field feedback';
		case 'not-found':
			return 'show empty state';
		case 'conflict':
			return 'refresh before retrying';
		case 'dependency-unavailable':
			return 'offer a retry';
		default:
			return 'show generic failure';
	}
}
What crosses the boundary
FieldPurposeWhat it must not be
codeStable machine branchA class name or English sentence
messageSafe human proseA database, provider, or stack trace detail
traceIdJoin response to server evidenceA replacement for logging the original cause
Optional dataOnly what the client can act onAccidental serialization of the whole error
Read the Go twinGo · classify, then marshal
twin.go · translated envelope
var ErrOrderNotFound = errors.New("order not found")

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string { return "invalid order input: " + e.Field }

type ConflictError struct{ Expected, Actual int }

func (e *ConflictError) Error() string { return "order version conflict" }

type DependencyError struct{ RetryAfterSeconds int }

func (e *DependencyError) Error() string { return "payment provider request failed" }

type PublicError struct {
	Code              string `json:"code"`
	Message           string `json:"message"`
	TraceID           string `json:"traceId"`
	RetryAfterSeconds int    `json:"retryAfterSeconds,omitempty"`
}

func Translate(err error, traceID string) (int, PublicError) {
	var validation *ValidationError
	var conflict *ConflictError
	var dependency *DependencyError
	switch {
	case errors.As(err, &validation):
		return 400, PublicError{Code: "invalid-input", Message: "Check the highlighted fields.", TraceID: traceID}
	case errors.Is(err, ErrOrderNotFound):
		return 404, PublicError{Code: "not-found", Message: "We could not find that order.", TraceID: traceID}
	case errors.As(err, &conflict):
		return 409, PublicError{Code: "conflict", Message: "This order changed. Refresh and try again.", TraceID: traceID}
	case errors.As(err, &dependency):
		return 503, PublicError{
			Code: "dependency-unavailable", Message: "Payments are temporarily unavailable.",
			TraceID: traceID, RetryAfterSeconds: dependency.RetryAfterSeconds,
		}
	default:
		return 500, PublicError{Code: "unknown", Message: "Something went wrong.", TraceID: traceID}
	}
}


func WireResponse(err error, traceID string) []byte {
	_, body := Translate(err, traceID)
	encoded, _ := json.Marshal(body)
	return encoded
}

The Go handler can use errors.Is or errors.As inside the service, but it returns a separate PublicError. The response does not promise that the caller can unwrap the private error; only the trace link crosses.

See the complete programsCopyable source plus invocation
TypeScript
errors.ts
export type InternalFailure =
	| { kind: 'invalid-input'; message: string; detail: string }
	| { kind: 'not-found'; message: string; detail: string }
	| { kind: 'conflict'; message: string; detail: string }
	| { kind: 'dependency-unavailable'; message: string; detail: string; retryAfterSeconds: number }
	| { kind: 'unknown'; message: string; detail: string };

// The public codes are their own list. Today each internal kind maps to one of them, but a new
// internal kind must be mapped on purpose instead of appearing on the wire by accident.
export type PublicCode =
	'invalid-input' | 'not-found' | 'conflict' | 'dependency-unavailable' | 'unknown';

export type PublicError = Readonly<{
	code: PublicCode;
	message: string;
	traceId: string;
	retryAfterSeconds?: number;
}>;

export type HttpResponse = Readonly<{ status: number; body: unknown }>;

const internalFailure: InternalFailure = {
	kind: 'dependency-unavailable',
	message: 'payment provider request failed',
	detail: 'payments: dial tcp 10.0.0.4:443: i/o timeout',
	retryAfterSeconds: 5
};

/** The tempting form: put the internal error sentence on the wire. */
export function canonicalResponse(error: Error): HttpResponse {
	return {
		status: 500,
		body: { message: error.message, stack: error.stack, cause: error.cause }
	};
}

export function canonicalHandler(error: unknown): HttpResponse {
	if (error instanceof Error) {
		return canonicalResponse(error);
	}
	return { status: 500, body: { message: String(error) } };
}

const publicMessages: Record<PublicCode, string> = {
	'invalid-input': 'Check the highlighted fields.',
	'not-found': 'We could not find that order.',
	conflict: 'This order changed. Refresh and try again.',
	'dependency-unavailable': 'Payments are temporarily unavailable.',
	unknown: 'Something went wrong.'
};

function isInternalFailure(value: unknown): value is InternalFailure {
	if (typeof value !== 'object' || value === null || !('kind' in value)) return false;
	const kind = (value as { kind?: unknown }).kind;
	return (
		kind === 'invalid-input' ||
		kind === 'not-found' ||
		kind === 'conflict' ||
		kind === 'dependency-unavailable' ||
		kind === 'unknown'
	);
}

export function translate(error: unknown, traceId: string): HttpResponse {
	if (!isInternalFailure(error)) {
		return {
			status: 500,
			body: { code: 'unknown', message: publicMessages.unknown, traceId }
		};
	}

	const status =
		error.kind === 'invalid-input'
			? 400
			: error.kind === 'not-found'
				? 404
				: error.kind === 'conflict'
					? 409
					: error.kind === 'dependency-unavailable'
						? 503
						: 500;
	const body: PublicError = {
		code: error.kind,
		message: publicMessages[error.kind],
		traceId,
		...(error.kind === 'dependency-unavailable'
			? { retryAfterSeconds: error.retryAfterSeconds }
			: {})
	};
	return { status, body };
}

export function clientState(response: HttpResponse): string {
	if (typeof response.body !== 'object' || response.body === null || !('code' in response.body)) {
		return 'show generic failure';
	}
	const code = (response.body as { code?: unknown }).code;
	switch (code) {
		case 'invalid-input':
			return 'show field feedback';
		case 'not-found':
			return 'show empty state';
		case 'conflict':
			return 'refresh before retrying';
		case 'dependency-unavailable':
			return 'offer a retry';
		default:
			return 'show generic failure';
	}
}

export function observe() {
	const privateCause = new Error(internalFailure.message, { cause: internalFailure.detail });
	const translated = translate(internalFailure, 'trace-7f42');
	return {
		canonical: canonicalHandler(privateCause),
		translated,
		client: clientState(translated)
	};
}

export function runExample() {
	return observe();
}

console.log(JSON.stringify(runExample(), null, 2));
Go
boundary.go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

// CanonicalResponse makes the internal sentence the public contract.
func CanonicalResponse(err error) []byte {
	body, _ := json.Marshal(map[string]string{
		"message": err.Error(),
		"detail":  fmt.Sprintf("%#v", err),
	})
	return body
}

var ErrOrderNotFound = errors.New("order not found")

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string { return "invalid order input: " + e.Field }

type ConflictError struct{ Expected, Actual int }

func (e *ConflictError) Error() string { return "order version conflict" }

type DependencyError struct{ RetryAfterSeconds int }

func (e *DependencyError) Error() string { return "payment provider request failed" }

type PublicError struct {
	Code              string `json:"code"`
	Message           string `json:"message"`
	TraceID           string `json:"traceId"`
	RetryAfterSeconds int    `json:"retryAfterSeconds,omitempty"`
}

func Translate(err error, traceID string) (int, PublicError) {
	var validation *ValidationError
	var conflict *ConflictError
	var dependency *DependencyError
	switch {
	case errors.As(err, &validation):
		return 400, PublicError{Code: "invalid-input", Message: "Check the highlighted fields.", TraceID: traceID}
	case errors.Is(err, ErrOrderNotFound):
		return 404, PublicError{Code: "not-found", Message: "We could not find that order.", TraceID: traceID}
	case errors.As(err, &conflict):
		return 409, PublicError{Code: "conflict", Message: "This order changed. Refresh and try again.", TraceID: traceID}
	case errors.As(err, &dependency):
		return 503, PublicError{
			Code: "dependency-unavailable", Message: "Payments are temporarily unavailable.",
			TraceID: traceID, RetryAfterSeconds: dependency.RetryAfterSeconds,
		}
	default:
		return 500, PublicError{Code: "unknown", Message: "Something went wrong.", TraceID: traceID}
	}
}


func WireResponse(err error, traceID string) []byte {
	_, body := Translate(err, traceID)
	encoded, _ := json.Marshal(body)
	return encoded
}

func main() {
	failure := fmt.Errorf("charge order 7: %w", &DependencyError{RetryAfterSeconds: 5})

	fmt.Printf("canonical: 500 %s\n", CanonicalResponse(failure))

	status, _ := Translate(failure, "trace-7f42")
	fmt.Printf("translated: %d %s\n", status, WireResponse(failure, "trace-7f42"))
}

Save the TypeScript as errors.ts and run node errors.ts (Node 22.18 or later runs TypeScript directly). Save the Go as boundary.go and run go run boundary.go. Each prints the leaking response first, then the translated one with the same code, status, and trace ID.

Change the policy

Keep the failure fixed. Watch the contract change.

Choose an internal failure and one of three boundary policies. Compare not just the status code, but the information that survives and the branch the client can safely take.

A controlled comparison

Keep the failure fixed. Change the wire contract.

Runs a local translation model
ProducerDependency unavailable
Wire503 · Translate to a stable envelope
ClientOffer a retry
Response body {"code":"dependency-unavailable","message":"Payments are temporarily unavailable.","traceId":"trace-7f42","retryAfterSeconds":5}
PreservedStable code, safe message, trace link, and a retry hint when owned
Leak riskNo · only deliberate public fields cross
Trace linkYes · trace-7f42 connects the body to server-side evidence

Known cases keep a client branch without asking the client to know the private implementation.

The controls change a local model; nothing is saved.
Read the client call siteTypeScript · branch on the public code
errors.ts
export function observe() {
	const privateCause = new Error(internalFailure.message, { cause: internalFailure.detail });
	const translated = translate(internalFailure, 'trace-7f42');
	return {
		canonical: canonicalHandler(privateCause),
		translated,
		client: clientState(translated)
	};
}

export function runExample() {
	return observe();
}

The client does not need to know whether the server used a class, sentinel, wrapped error, or database driver. It validates the response, switches on its own public union, and keeps the trace ID available for a support link or log context.

Make the boundary explicit

Choose what the next side can act on.

A useful public error is intentionally smaller than the private one. Practice choosing the stable part before deciding how to encode it.

A payment timeout should let the client offer a retry.
An unknown invariant failure reaches a public HTTP handler.
A client needs to branch on “record missing” after a deploy.
Feedback stays on this page; it is not saved.
A production boundary

Translate once, close to the contract owner.

The service layer owns the cause and its internal vocabulary. The HTTP or messaging adapter owns the public response. The client-side request layer owns decoding and turns the response into a local state. Keeping those jobs at their edges prevents every view from learning a different provider failure string.

Record the original failure with the trace ID before returning the public body. For a known case, log enough context to investigate without sending that context to an untrusted or independently deployed caller. For an unknown case, do not invent a retriable or user-actionable code merely to avoid a generic response. The examples leave that logging call out so their output stays short; the trace ID is the join key it would use.

Producer

Keep the cause

Wrap the driver or domain failure with private context.

Adapter

Translate once

Map known cases to status, code, safe prose, and trace ID.

Client

Decode a union

Render a recovery branch without parsing server sentences.

TypeScript

TypeScript · public contract

errors.ts · public contract
// The public codes are their own list. Today each internal kind maps to one of them, but a new
// internal kind must be mapped on purpose instead of appearing on the wire by accident.
export type PublicCode =
	'invalid-input' | 'not-found' | 'conflict' | 'dependency-unavailable' | 'unknown';

export type PublicError = Readonly<{
	code: PublicCode;
	message: string;
	traceId: string;
	retryAfterSeconds?: number;
}>;
Go

Go · boundary mapper

twin.go · boundary mapper
type PublicError struct {
	Code              string `json:"code"`
	Message           string `json:"message"`
	TraceID           string `json:"traceId"`
	RetryAfterSeconds int    `json:"retryAfterSeconds,omitempty"`
}

func Translate(err error, traceID string) (int, PublicError) {
	var validation *ValidationError
	var conflict *ConflictError
	var dependency *DependencyError
	switch {
	case errors.As(err, &validation):
		return 400, PublicError{Code: "invalid-input", Message: "Check the highlighted fields.", TraceID: traceID}
	case errors.Is(err, ErrOrderNotFound):
		return 404, PublicError{Code: "not-found", Message: "We could not find that order.", TraceID: traceID}
	case errors.As(err, &conflict):
		return 409, PublicError{Code: "conflict", Message: "This order changed. Refresh and try again.", TraceID: traceID}
	case errors.As(err, &dependency):
		return 503, PublicError{
			Code: "dependency-unavailable", Message: "Payments are temporarily unavailable.",
			TraceID: traceID, RetryAfterSeconds: dependency.RetryAfterSeconds,
		}
	default:
		return 500, PublicError{Code: "unknown", Message: "Something went wrong.", TraceID: traceID}
	}
}
Build UIs?Your request layer is already an error boundary.

Where it already is in your components

A view that renders “try again,” “not found,” or “edit conflict” is consuming a translated contract. Put the decoding and mapping in the request layer so buttons do not each interpret a different version of the response.

When you have to own it

When the backend and frontend deploy independently, document the public codes, unknown-code fallback, and optional fields. When the UI is server-rendered in the same process, you may share an internal type—but keep the public boundary explicit if that response can later travel.

Recognize it elsewhere

Most protocols already separate diagnosis from action.

HTTP status

A transport-level category such as 404 or 503 tells a broad story, not the complete client contract.

traceparent

A trace context links work across processes. It complements a public error code; it does not replace one.

Validation problems

Field-level feedback is useful because the client can act on it. A database constraint sentence usually is not.

Compare local error identity ↗
The parts to watch

A thin error is still a contract to maintain.

Do not use message text as a code

Messages are for people and can be translated, clarified, or rewritten. A client that searches for “timeout” is coupled to prose. Give a branch a documented code and define what happens when that code is unknown.

Do not serialize the whole error object

Enumerable properties, causes, stack traces, and driver metadata can reveal secrets or internal topology. Build the public object field by field. An allowlist is easier to review than trying to remove sensitive fields after serialization.

A trace ID is not private evidence

The ID is a lookup key, not the log itself. Keep authorization and retention rules around the server-side evidence, and never make the public response depend on a client being able to read internal logs.

Unknown cases need a stable fallback

A new server failure will eventually reach an older client. The decoder should reject malformed or unknown codes into a generic state. Forward compatibility means the client remains safe while deployments move at different speeds.

Make the call

Preserve action, not implementation.

Translate at the first boundary that owns the public contract. Keep the internal failure rich enough for diagnosis, but make the wire body small enough to document, validate, and evolve.

Local call

Keep the rich cause.

Use the local error protocol while ownership remains inside the process.

Known public case

Return code + safe data.

Expose only the fields needed for the next action.

Unknown public case

Generic code + trace.

Protect the boundary while preserving a route back to the evidence.

Take the idea with you

The error changes shape at the edge.

Inside, preserve causes so the owning code can recognize and diagnose a failure. At the edge, translate into data the next side can safely branch on. After the edge, decode that data into a new local state. The public body is not a remote exception; it is a message contract.

This lesson begins where local recognition stops: the moment another process receives only your chosen fields.

Why
Let the next side take a safe, stable action.
What
Translate known failures; genericize unknown ones; keep a trace link.
Constraint
Client and service deploy independently, so the codes must stay stable.
Fallback
An unknown failure becomes a generic code with the trace ID.
Reconsider when
The boundary ownership or the client actions change.
Connections to follow nextRelated lessons
Explore more concepts & practices →