01 Structured errors 6 chapters

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.

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