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
/** 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.
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.
Serialize the internal error
Convenient at first; the driver’s vocabulary becomes the contract.
/** 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) } };
} Translate to a public envelope
Known cases keep recovery data; unknown cases become safe and traceable.
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';
}
} | Field | Purpose | What it must not be |
|---|---|---|
code | Stable machine branch | A class name or English sentence |
message | Safe human prose | A database, provider, or stack trace detail |
traceId | Join response to server evidence | A replacement for logging the original cause |
| Optional data | Only what the client can act on | Accidental serialization of the whole error |
Read the Go twinGo · classify, then marshal
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
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));
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.
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.
Keep the failure fixed. Change the wire contract.
{"code":"dependency-unavailable","message":"Payments are temporarily unavailable.","traceId":"trace-7f42","retryAfterSeconds":5}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
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.
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.
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.
Keep the cause
Wrap the driver or domain failure with private context.
Translate once
Map known cases to status, code, safe prose, and trace ID.
Decode a union
Render a recovery branch without parsing server sentences.
TypeScript · 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 · 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.
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.
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.
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.
Keep the rich cause.
Use the local error protocol while ownership remains inside the process.
Return code + safe data.
Expose only the fields needed for the next action.
Generic code + trace.
Protect the boundary while preserving a route back to the evidence.
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
- Result types and combinators compose expected failures before they reach the adapter.
- Class-based error hierarchies cover local recognition inside one package.
- Kinds and sentinels follows how a public code evolves once clients depend on it.
- Error boundaries in UI is what the client does with the response after it decodes it.