Advanced (Updated: 6/3/2026)

Error Handling Patterns for Claude Code: Classify Failures at the Boundary

A practical Claude Code guide to error handling with TypeScript, API validation, external API failures, and batch jobs.

Error Handling Patterns for Claude Code: Classify Failures at the Boundary

Error handling is not the same as adding more try-catch blocks. When you ask Claude Code to fix a production bug, vague errors usually lead to vague patches: every failure becomes 500, logs are hard to search, and nobody can tell whether the user should retry, edit the request, or wait for an external service to recover.

The useful pattern is simple: classify failures at the boundary and make the recovery path explicit. A boundary is the place where your application meets something less predictable than your own code: an API request body, a payment provider, a CRM, a scheduled job, a CSV import, or a browser screen. Once the boundary is clear, Claude Code can implement a much safer rule: validation errors return 400, temporary external failures can be retried, and batch failures are stored with enough context to replay them.

This article rewrites the usual error-handling advice into a workflow that works well with Claude Code. The goal is beginner-friendly but production-minded: one small TypeScript model, three concrete use cases, review prompts you can paste into Claude Code, and the operational pitfalls that tend to hurt teams later.

Start With Recovery, Not Names

The first decision is not whether the class should be called AppError, DomainError, or HttpError. The first decision is what the system should do next.

BoundaryExampleResponseRecovery
API validationInvalid email, out-of-range number, malformed JSON400Ask the user to fix the input
External APIBilling, CRM, email, analytics provider is down502 or 503Retry, pause, or surface a temporary failure
Job or batchNightly report, CSV import, notification workerInternal failure recordRetry safely, move to a dead-letter queue, alert a human

If the code only stores message, humans can read it but programs cannot make good decisions. Add machine-readable fields such as kind, code, retryable, and status. Then Claude Code can review behavior instead of guessing from strings.

A Result type is one practical way to do that. It does not mean exceptions are banned everywhere. It means that, at important boundaries, success and failure are returned in a shape that forces the caller to choose a recovery path.

Copy-Paste TypeScript Example

The following example runs on Node.js 18 or newer. It uses tsx so you can execute TypeScript directly. The external API example does not call the network; it injects a fake fetcher, which makes the failure path reproducible.

npm install -D tsx typescript
npx tsx error-patterns-demo.ts

Save this as error-patterns-demo.ts.

type ErrorKind = "validation" | "external" | "job" | "unexpected";

type AppError = {
  kind: ErrorKind;
  code: string;
  message: string;
  retryable: boolean;
  status: number;
  details?: Record<string, unknown>;
  cause?: unknown;
};

type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: AppError };

const ok = <T>(value: T): Result<T> => ({ ok: true, value });
const fail = <T>(error: AppError): Result<T> => ({ ok: false, error });

function validationError(code: string, message: string, details?: Record<string, unknown>): AppError {
  return { kind: "validation", code, message, retryable: false, status: 400, details };
}

function externalError(
  code: string,
  message: string,
  retryable: boolean,
  details?: Record<string, unknown>,
  cause?: unknown,
): AppError {
  return { kind: "external", code, message, retryable, status: retryable ? 503 : 502, details, cause };
}

function jobError(code: string, message: string, details?: Record<string, unknown>, cause?: unknown): AppError {
  return { kind: "job", code, message, retryable: true, status: 500, details, cause };
}

type CreateUserInput = {
  email: string;
  age: number;
  plan: "free" | "pro";
};

export function parseCreateUserInput(body: unknown): Result<CreateUserInput> {
  if (typeof body !== "object" || body === null) {
    return fail(validationError("BODY_REQUIRED", "Request body must be an object"));
  }

  const record = body as Record<string, unknown>;
  const email = record.email;
  const age = record.age;
  const plan = record.plan ?? "free";

  if (typeof email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    return fail(validationError("EMAIL_INVALID", "Email address is invalid", { field: "email" }));
  }
  if (typeof age !== "number" || !Number.isInteger(age) || age < 13) {
    return fail(validationError("AGE_INVALID", "Age must be an integer of 13 or greater", { field: "age" }));
  }
  if (plan !== "free" && plan !== "pro") {
    return fail(validationError("PLAN_INVALID", "Plan must be free or pro", { field: "plan" }));
  }

  return ok({ email, age, plan });
}

type HttpResponse = { status: number; body: unknown };

export function toHttpResponse<T>(result: Result<T>): HttpResponse {
  if (result.ok) return { status: 200, body: { data: result.value } };

  const { code, message, retryable, details } = result.error;
  return {
    status: result.error.status,
    body: { error: { code, message, retryable, details } },
  };
}

type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<Response>;

export async function callBillingApi(
  userId: string,
  fetcher: FetchLike = fetch,
): Promise<Result<{ customerId: string }>> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 3000);

  try {
    const response = await fetcher(`https://billing.example.test/customers/${userId}`, {
      signal: controller.signal,
    });

    if (!response.ok) {
      return fail(
        externalError("BILLING_HTTP_ERROR", `Billing API returned ${response.status}`, response.status >= 500, {
          status: response.status,
        }),
      );
    }

    const payload = (await response.json()) as { customerId?: unknown };
    if (typeof payload.customerId !== "string") {
      return fail(externalError("BILLING_BAD_PAYLOAD", "Billing API payload was invalid", false, { payload }));
    }

    return ok({ customerId: payload.customerId });
  } catch (cause) {
    return fail(externalError("BILLING_UNREACHABLE", "Billing API could not be reached", true, undefined, cause));
  } finally {
    clearTimeout(timer);
  }
}

export async function runJob<T>(
  name: string,
  work: () => Promise<T>,
  options: { retries: number; delayMs: number } = { retries: 2, delayMs: 100 },
): Promise<Result<T>> {
  for (let attempt = 1; attempt <= options.retries + 1; attempt += 1) {
    try {
      return ok(await work());
    } catch (cause) {
      if (attempt <= options.retries) {
        await sleep(options.delayMs);
        continue;
      }
      return fail(jobError("JOB_FAILED", `${name} failed after ${attempt} attempts`, { attempt }, cause));
    }
  }

  return fail(jobError("JOB_FAILED", `${name} failed unexpectedly`));
}

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function demo() {
  console.log("validation:", toHttpResponse(parseCreateUserInput({ email: "bad", age: 10 })));

  const billing = await callBillingApi("user_123", async () => new Response("Service unavailable", { status: 503 }));
  console.log("external:", billing);

  let count = 0;
  const job = await runJob("daily-report", async () => {
    count += 1;
    if (count < 2) throw new Error("temporary lock");
    return { exportedRows: 42 };
  });
  console.log("job:", job);
}

demo().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Use Case 1: API Validation

Validation errors are user-fixable failures. Invalid email, invalid JSON, an unknown plan name, or an age below the allowed range should not look like an internal server crash. In the example, parseCreateUserInput returns kind: "validation", status: 400, and retryable: false.

That is more precise than throwing a generic Error. The frontend can highlight a field. The API can return a stable error code. Logs can be filtered by EMAIL_INVALID. Claude Code can add tests for the boundary without needing to infer behavior from a sentence.

Paste this prompt when you want Claude Code to review validation behavior:

Review the API validation error handling.
- Classify request-body failures as validation errors at the boundary.
- Return 400 for failures the user can fix.
- Use machine-readable codes such as EMAIL_INVALID.
- Do not expose SQL names, stack traces, or secrets in the response.
- Add or update failure-path tests, not only happy-path tests.

For a deeper testing workflow, connect this article with API testing with Claude Code and Claude Code testing strategies.

Use Case 2: External API Failures

External services fail even when your code is correct. Payment providers, email vendors, CRMs, spreadsheets, and analytics tools all create uncertainty at the boundary. The dangerous shortcut is to label every provider issue as “external failure” and move on.

Separate retryable failures from non-retryable failures. A 503, timeout, or temporary network failure may deserve a retry. A malformed response payload usually does not. Retrying bad payloads only creates more queue noise and hides the real contract mismatch.

The example uses Response.ok, checks the payload shape, and applies a timeout with AbortController. Confirm the response semantics with the official MDN Response.ok documentation. If your API uses Express, also review Express error handling, especially where error middleware belongs in the request chain.

Use Case 3: Job and Batch Failures

Jobs fail differently from request/response APIs. A nightly report, CSV import, invoice export, or email sender may fail after the user has left the screen. If the job simply logs Error: failed, operations have no clean way to replay it.

The runJob helper stores the job name, final attempt count, and retryability. In a real system, that result should be written to structured logs, a dead-letter queue, an admin screen, or an alert. The key question is not “did we catch the error?” The key question is “can we safely run this again?”

Idempotency matters here. If retrying a CSV import creates duplicate rows, the error handler is not safe. If retrying a billing job charges the customer twice, retry logic becomes a production incident. Make Claude Code review the replay behavior, not just the syntax.

Common Pitfalls

The first pitfall is swallowing errors with catch { return null; }. That removes the evidence Claude Code needs to debug the issue. Keep a stable code, kind, retryable, and, where safe, a cause.

The second pitfall is leaking internal details. Stack traces, SQL names, environment variable names, and token fragments should never be returned to the user. For the security side, read Claude Code security audit automation.

The third pitfall is retrying everything. Validation failures and bad provider payloads usually need correction, not repetition. Retry only failures that time can realistically fix: temporary network errors, rate limits, and lock conflicts.

The fourth pitfall is adding types without adding team rules. TypeScript narrowing and discriminated unions are powerful, but a teammate can still add throw new Error("failed") tomorrow. Use the official TypeScript Narrowing guide, then write the project rule into AGENTS.md or CLAUDE.md.

Claude Code Review Prompt

After implementation, ask Claude Code to review operations, not only code style:

Review error handling in this PR.
Check:
1. Failures are classified at API, external-service, and job boundaries.
2. User-fixable failures do not become 500 responses.
3. Retryable and non-retryable failures are separated.
4. Responses do not expose stack traces, SQL, or secrets.
5. Logs include code, kind, attempt, and safe cause information.
6. Tests cover at least three failure paths.
Return the smallest safe patch and the tests that prove it.

If you want to drive the change test-first, pair this with TDD with Claude Code. For debugging after the failure is found, use Claude Code debugging techniques.

Official References and CTA

Use official references when you ask Claude Code to reason about behavior. Besides MDN, Express, and TypeScript, Node’s test runner is useful for locking down failure paths without adding a test framework. For Claude Code settings and project memory, start from the Claude Code overview.

If you want a small starting point, download the free cheat sheet. If you want prompts, review checklists, and team-ready templates, browse the product library. If your team needs to retrofit error handling into an existing repository, the Claude Code training and consulting page is the best next step.

What Masa Tried

In Masa’s own workflow, moving validation, external API, and job failures into one AppError shape made Claude Code prompts shorter and reviews more reliable. Instead of asking “fix this error nicely,” the prompt can now say: validation returns 400, external failures set retryable, and jobs keep attempt. The review then focuses on three surfaces: response, log, and test. It is not a magic architecture, but it is far easier to operate than a codebase full of anonymous 500 responses.

#Claude Code #error handling #design patterns #TypeScript #robustness
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.