Theme 04 · Debuggability-Focused
Structured errors
Explanation
Structured errors
Plain Human Explanation
When something breaks, the team should be able to answer three questions quickly: what happened, who or what was affected, and what should happen next. A structured error gives that answer in fields, not only in a sentence.
Plain error text is useful for humans, but it is hard to group across logs, alerts, and support tickets. A stable error code lets the product team see “payment provider timeout” as one repeated issue instead of fifty slightly different messages.
Technical Explanation
Use named error codes, safe context, and an optional original cause. The code should stay stable enough for dashboards and runbooks. The message can be readable, but it should not be the only thing machines can inspect.
In TypeScript, this can be a small error class, a result object, or a tagged union. The important part is that expected fields are explicit: code, message, context, and sometimes cause.
Why It Matters
- User impact: support can explain the problem without asking engineering to reverse-engineer a stack trace.
- Product behavior: retries, alerts, and user-facing messages can depend on the kind of failure, not on fragile text matching.
- Risk: unstructured errors get copied into logs with missing context or too much private context.
- Decision point: structure errors where the team will need to search, group, retry, escalate, or explain failures.
The Core Move
Give recurring failures stable names and safe facts. Keep the human explanation readable, but make the machine-readable fields carry the operational meaning.
Small Example
Structured errors: Small Example
Bad TypeScript Example
export function missingPlanError(userId: string, planId: string): Error {
return new Error(`Could not find plan ${planId} for user ${userId}`);
}
export function missingPlanError(
userId: string,
planId: string,
): Error {
return new Error(
`Could not find plan ${planId} for user ${userId}`,
);
}
Good TypeScript Example
type ErrorCode = "checkout.plan_not_found";
type ErrorContext = {
userId: string;
planId: string;
};
export class AppError extends Error {
constructor(
readonly code: ErrorCode,
message: string,
readonly context: ErrorContext,
readonly cause?: unknown,
) {
super(message);
this.name = "AppError";
}
}
export function missingPlanError(userId: string, planId: string): AppError {
return new AppError("checkout.plan_not_found", "Selected plan is not available.", {
userId,
planId,
});
}
type ErrorCode = "checkout.plan_not_found";
type ErrorContext = {
userId: string;
planId: string;
};
export class AppError extends Error {
constructor(
readonly code: ErrorCode,
message: string,
readonly context: ErrorContext,
readonly cause?: unknown,
) {
super(message);
this.name = "AppError";
}
}
export function missingPlanError(
userId: string,
planId: string,
): AppError {
return new AppError(
"checkout.plan_not_found",
"Selected plan is not available.",
{
userId,
planId,
},
);
}
What Changed
- The bad version only gives humans a sentence to search for.
- The good version gives the failure a stable code that logs, alerts, and tests can depend on.
- The context is explicit and safe, so the caller does not need to dump a whole request to understand the error.
Realistic Example
Structured errors: Realistic Example
This example uses checkout creation. The important debugging question is whether a failed checkout was caused by input, the payment provider, or persistence.
Bad TypeScript Example
export async function startCheckout(req: any, provider: any, logger: any) {
try {
return await provider.createSession(req.body.userId, req.body.priceId);
} catch (error) {
logger.error("checkout failed", { req, error });
throw new Error("Checkout failed");
}
}
export async function startCheckout(
req: any,
provider: any,
logger: any,
) {
try {
return await provider.createSession(
req.body.userId,
req.body.priceId,
);
} catch (error) {
logger.error("checkout failed", {
req,
error,
});
throw new Error("Checkout failed");
}
}
Good TypeScript Example
type CheckoutRequest = {
userId: string;
priceId: string;
};
type CheckoutSession = {
id: string;
url: string;
};
type CheckoutError =
| {
code: "checkout.invalid_request";
message: string;
context: {
field: "userId" | "priceId";
};
}
| {
code: "checkout.provider_unavailable";
message: string;
context: {
userId: string;
priceId: string;
providerRequestId?: string;
};
cause: unknown;
};
type CheckoutResult = { ok: true; session: CheckoutSession } | { ok: false; error: CheckoutError };
type PaymentProvider = {
createSession(request: CheckoutRequest): Promise<CheckoutSession>;
};
export async function startCheckout(request: CheckoutRequest, provider: PaymentProvider): Promise<CheckoutResult> {
if (request.userId.length === 0) {
return {
ok: false,
error: {
code: "checkout.invalid_request",
message: "Checkout requires a user.",
context: { field: "userId" },
},
};
}
try {
const session = await provider.createSession(request);
return { ok: true, session };
} catch (cause) {
return {
ok: false,
error: {
code: "checkout.provider_unavailable",
message: "Payment provider could not create a checkout session.",
context: { userId: request.userId, priceId: request.priceId },
cause,
},
};
}
}
type CheckoutRequest = {
userId: string;
priceId: string;
};
type CheckoutSession = {
id: string;
url: string;
};
type CheckoutError =
| {
code: "checkout.invalid_request";
message: string;
context: {
field: "userId" | "priceId";
};
}
| {
code: "checkout.provider_unavailable";
message: string;
context: {
userId: string;
priceId: string;
providerRequestId?: string;
};
cause: unknown;
};
type CheckoutResult =
| {
ok: true;
session: CheckoutSession;
}
| {
ok: false;
error: CheckoutError;
};
type PaymentProvider = {
createSession(
request: CheckoutRequest,
): Promise<CheckoutSession>;
};
export async function startCheckout(
request: CheckoutRequest,
provider: PaymentProvider,
): Promise<CheckoutResult> {
if (request.userId.length === 0) {
return {
ok: false,
error: {
code: "checkout.invalid_request",
message:
"Checkout requires a user.",
context: {
field: "userId",
},
},
};
}
try {
const session =
await provider.createSession(request);
return {
ok: true,
session,
};
} catch (cause) {
return {
ok: false,
error: {
code: "checkout.provider_unavailable",
message:
"Payment provider could not create a checkout session.",
context: {
userId: request.userId,
priceId: request.priceId,
},
cause,
},
};
}
}
What Changed
- The bad version collapses every provider problem into the same generic thrown error.
- The good version separates invalid input from provider failure, so the caller can respond differently.
- The structured context gives support useful facts without logging the entire request body.
System Example
Structured errors: System Example
At system scale, structured errors keep route handling, logging, and support behavior aligned. The route can return a user-safe response while logs still keep the operational detail.
Larger System-Level Bad TypeScript Example
export async function checkoutRoute(req: any, res: any, services: any) {
try {
const session = await services.checkout.start(req.body);
res.status(200).json(session);
} catch (error) {
services.logger.error("checkout route failed", { error, body: req.body });
res.status(500).json({ message: "Something went wrong" });
}
}
export async function checkoutRoute(
req: any,
res: any,
services: any,
) {
try {
const session =
await services.checkout.start(
req.body,
);
res.status(200).json(session);
} catch (error) {
services.logger.error(
"checkout route failed",
{
error,
body: req.body,
},
);
res.status(500).json({
message: "Something went wrong",
});
}
}
Larger System-Level Good TypeScript Example
type CheckoutCommand = {
userId: string;
priceId: string;
};
type CheckoutSession = {
id: string;
url: string;
};
type StructuredError = {
code: "checkout.invalid_request" | "checkout.provider_unavailable";
message: string;
context: Record<string, string>;
cause?: unknown;
};
type CheckoutResult =
| { ok: true; session: CheckoutSession }
| { ok: false; error: StructuredError };
type CheckoutService = {
start(command: CheckoutCommand): Promise<CheckoutResult>;
};
type Logger = {
warn(event: string, fields: Record<string, unknown>): void;
error(event: string, fields: Record<string, unknown>): void;
};
function statusFor(error: StructuredError): 400 | 503 {
if (error.code === "checkout.invalid_request") return 400;
return 503;
}
export async function checkoutRoute(
command: CheckoutCommand,
checkout: CheckoutService,
logger: Logger,
) {
const result = await checkout.start(command);
if (result.ok) {
return { status: 200, body: { checkoutUrl: result.session.url } };
}
const logFields = {
code: result.error.code,
userId: result.error.context.userId,
priceId: result.error.context.priceId,
};
if (result.error.code === "checkout.invalid_request") {
logger.warn("checkout.rejected", logFields);
} else {
logger.error("checkout.failed", { ...logFields, cause: result.error.cause });
}
return { status: statusFor(result.error), body: { message: result.error.message } };
}
type CheckoutCommand = {
userId: string;
priceId: string;
};
type CheckoutSession = {
id: string;
url: string;
};
type StructuredError = {
code:
| "checkout.invalid_request"
| "checkout.provider_unavailable";
message: string;
context: Record<string, string>;
cause?: unknown;
};
type CheckoutResult =
| {
ok: true;
session: CheckoutSession;
}
| {
ok: false;
error: StructuredError;
};
type CheckoutService = {
start(
command: CheckoutCommand,
): Promise<CheckoutResult>;
};
type Logger = {
warn(
event: string,
fields: Record<string, unknown>,
): void;
error(
event: string,
fields: Record<string, unknown>,
): void;
};
function statusFor(
error: StructuredError,
): 400 | 503 {
if (
error.code ===
"checkout.invalid_request"
)
return 400;
return 503;
}
export async function checkoutRoute(
command: CheckoutCommand,
checkout: CheckoutService,
logger: Logger,
) {
const result =
await checkout.start(command);
if (result.ok) {
return {
status: 200,
body: {
checkoutUrl: result.session.url,
},
};
}
const logFields = {
code: result.error.code,
userId: result.error.context.userId,
priceId: result.error.context.priceId,
};
if (
result.error.code ===
"checkout.invalid_request"
) {
logger.warn(
"checkout.rejected",
logFields,
);
} else {
logger.error("checkout.failed", {
...logFields,
cause: result.error.cause,
});
}
return {
status: statusFor(result.error),
body: {
message: result.error.message,
},
};
}
What Changed
- The bad route logs the request body and returns the same response for every failure.
- The good route maps stable error codes to user-safe status codes and messages.
- Logs keep the fields needed for debugging without making the route inspect free-form error text.
When To Use It
Structured errors: When To Use It
Use This When
- A failure will appear in support tickets, alerts, retry queues, or customer-facing responses.
- Different failures need different handling, even if they share the same high-level workflow.
- The team needs to group repeated incidents by cause instead of reading every stack trace manually.
Avoid This When
- The error is truly local and no caller can do anything useful with a code.
- You would be inventing dozens of codes that nobody will search, chart, or handle.
- The context would include secrets, payment details, or full request bodies just to feel complete.
Tradeoffs
Structured errors add a small amount of naming work. The payoff is faster triage and safer automation. Keep the code list short enough that people can remember the important categories.
Related Concepts
- Typed failure modes
- Safe telemetry
- Tracing
Practice Prompt
Structured errors: Practice Prompt
Beginner Exercise
Find a place that throws new Error("Something failed"). Replace the message with a stable code, a readable message, and two safe context fields.
Intermediate Exercise
Pick a route or job that catches an error. Return or throw a structured error for one expected failure and keep the original cause attached for unexpected infrastructure failure.
Stretch Exercise
Create a small mapping from structured error codes to HTTP status codes, retry decisions, or support categories. Add tests that fail if a new code is not handled.
Reflection Question
Which error fields would help support explain the issue, and which fields would be unsafe or noisy to include?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.