# 14.5 Errors as design (/building-ai-workers/structuring-programs/errors-as-design)

---
type: Document
title: "14.5 Errors as design"
description: "What a program should do when its edge refuses data: when to stop, why to collect every bad row, and what the report tells the person who owns the data."
status: stable
order: 314.05
ksor:
  owner: team:panaversity
  audience: [ public ]
  approval:
    by: process:panaversity
    at: 2026-11-02T06:57:40Z
chapter: "14"
part: III
expert_status: builder-only
concepts: [ "14.5" ]
last_verified: 2026-10-30
sources:
  - id: pydantic-validation-errors
    title: "Validation errors (Pydantic, accessed 2026-10-30)"
    resource: https://pydantic.dev/docs/validation/latest/errors/validation_errors/
generated:
  at: 2026-11-02T06:57:40Z
  by: process:panaversity
trust_tier: unverified
build_id: sha256:e1fbcb7d9a8f8b9b7cb96875022325c02c9ba4be94cef13b301cbd39a9edad85
dirty: true
ksor_version: 0.0.60
---

**In everyday life.** A good accounts clerk who finds a problem in a batch of 200 invoices does not stop at the first one and walk away. She marks every problem with a sticky note that says what is wrong and what to do, and hands the whole batch back. One trip, not twenty.

**What you will learn.** How to design what happens when the [edge](data-models-and-schemas.md#why-check-at-the-edge) refuses: stop, collect every problem, and tell whoever owns the data what to fix.

**Why it matters.** When a program finds bad data, it should stop before it calculates anything, list every bad row and what to fix, and send the list to the person who can fix it. On the day nobody reads the code, the report is what somebody reads.

### When should a program stop?

When an input breaks a rule the program relies on, and before it calculates anything from that input.

An **exception** is an error that stops the normal flow of a program. Code raises one on purpose, with `throw` in TypeScript or `raise` in Python, and the language raises one when something fails, such as a missing file. Like [a payment the bank sends back](../programming-fundamentals/reading-an-error.md), it goes back to the code that called, with its reason. Code that expects a possible failure **catches** it, with `try` and `catch` in TypeScript or `try` and `except` in Python, and decides what to do. Good error handling is designed from the start, not added later.

| Design choice | What it means | Brightline example |
| --- | --- | --- |
| Fail loudly at the edge | Refuse bad input where it enters, with a clear message | The loader refuses a negative amount |
| Never fail silently | Do not turn an error into a default value | [Marcus's script](overview.md#why-it-matters) turned "not a number" into zero |
| Collect, do not stop at the first | In a batch, check every record, then report them all | All four bad rows in one report |
| Custom error types | A named kind of error, carrying its details | An `InvoiceFileError` could carry the list of bad rows |
| Catch only what you can handle | Catch the kinds of error you expect, and let the rest stop the run | Catch `InvoiceFileError`, and throw any other error again |
| A message says what and what next | What failed, where, and what to try | "line 7, terms must be one of the known terms" |

A refusal at the edge stops the run before any list or total exists. The program exits with a failure code, so whatever started the run knows it failed. Marcus's script reported success. The loader's count line, such as "11 valid, 4 errors in data/invoices-messy.csv", is [Chapter 11](../../managing-ai-workers/when-the-worker-goes-wrong/ran-versus-worked.md)'s rule written into the code. That rule says a run should say something every time.

### Stop at the first bad row, or collect them?

Collect them, so one report holds every problem.

**At work.** Brightline's messy export, `data/invoices-messy.csv`, has 15 rows and four problems. There is a negative amount on line 5, terms "Net 10" on line 7, and the date `2026-13-02` on line 11. On line 14, INV-8313's vendor id `V099` is not in the vendor list. The agent's first loader stopped at line 5. The test Ana decided on expected four errors, with line numbers, and the second version passed it. Now the loader reads the file, prints one line for each bad row and a count, and exits with a failure code.

**In TypeScript.** An excerpt from `src/load-invoices.ts`. It validates each row, adds the vendor lookup, and collects the problems. `candidate(row)` is the [controlled conversion](data-models-and-schemas.md#does-the-library-change-values-for-me) of 14.4. Plain whole numbers become numbers, and the rest is left for the schema to refuse. `forEach` runs the lines once for each row, and `i` counts the rows from 0. `a ? b : c` means "if a, then b, otherwise c", and `push` adds an item to the end of a list. Predict the five lines.

```ts
readRows(csv).forEach((row, i) => {
  const line = i + 2; // line 1 is the header
  const parsed = InvoiceSchema.safeParse(candidate(row));
  const problems = parsed.success ? [] : parsed.error.issues.map((x) => `${x.path.join(".")} ${x.message}`);
  if (!vendors.has(row.vendor_id)) problems.push(`vendorId ${row.vendor_id} is not in vendors.csv`);
  if (parsed.success && problems.length === 0) valid.push(parsed.data);
  else errors.push({ line, invoice: row.invoice_number, problems });
});
```

```text
$ npm run check-invoices -- data/invoices-messy.csv
line 5  INV-8304  amountCents must be zero or more
line 7  INV-8306  terms must be one of the known terms
line 11  INV-8310  invoiceDate must be a real date, YYYY-MM-DD
line 14  INV-8313  vendorId V099 is not in vendors.csv
11 valid, 4 errors in data/invoices-messy.csv
```

**In Python.** The same loop, with `try` and `except` around the one call that can raise. `enumerate` counts the rows from 0, as `i` does in TypeScript. `vendors` is the `VendorDirectory` of [14.3](classes-and-objects.md), and its `has` method says whether it holds an id.

```python
for i, row in enumerate(read_rows(csv)):
    line = i + 2  # line 1 is the header
    problems: list[str] = []
    try:
        model = InvoiceModel.model_validate(candidate(row))
    except ValidationError as err:
        for e in err.errors():
            problems.append(f"{e['loc'][0]} {e['msg']}")
    if not vendors.has(row["vendor_id"]):
        problems.append(f"vendor_id {row['vendor_id']} is not in vendors.csv")
```

```text
line 5  INV-8304  amount_cents Input should be greater than or equal to 0
line 7  INV-8306  terms Input should be 'Due on receipt', 'Net 15', 'Net 30', 'Net 45' or 'Net 60'
line 11  INV-8310  invoice_date Input should be a valid date or datetime, month value is outside expected range of 1-12
line 14  INV-8313  vendor_id V099 is not in vendors.csv
11 valid, 4 errors in ../data/invoices-messy.csv
```

### Who gets the report, and what does it say?

The person who owns the data, with every bad row and what to fix. Each line names the row, the invoice, the field and what is allowed, so the owner of the export does not need to ask any questions, and fixes all four at once. With nobody at Brightline watching, any run that reads through the loader prints the same lines and stops.

**Custom errors.** A **custom error type** is a class that extends the language's own error class, with a name of its own and any extra fields. Callers can then catch that one kind of error and not the others. An `InvoiceFileError`, for example, can carry the full list of bad rows, so the code that called the loader can print them all.

### When is a catch block wrong?

When it catches every error and continues, because that hides bugs. It is [Chapter 13](../programming-fundamentals/reading-an-error.md)'s mechanic, asked only to turn the oil light off. Look for it in agent-written code. When you see one, ask: which error is this for, and what does the code do about it? If the answer is "nothing", it should not be there.

**Where the languages differ.** TypeScript writes `throw` and `catch`, and Python writes `raise` and `except`. A TypeScript `catch` catches everything, so code inside it often checks the kind of error with `instanceof` and throws any other kind again, with `throw err`. A Python `except` names the kinds it catches, such as `except ValidationError`. A bare `except:` in Python, or an empty `catch {}` in TypeScript, catches every error and is the shape to question first.

### What does refusing cost?

One bad row stops the whole run until its owner fixes it. That is the price, and it is far smaller than Marcus's week of phone calls.

### Which code does the edge protect?

An edge protects only the code that reads through it. Brightline's Friday list is not protected this way yet. Ana reads `src/due-by-friday.ts`. It still reads its invoices through `invoiceFromRow`, not through the loader. Those records come in by a side door and skip the dock. So a blank amount, the case in [14.4](data-models-and-schemas.md#does-the-library-change-values-for-me), would still reach Friday's list as $0.00. She does not change it today. She writes it down in `ports/ch14.md` as the next change: the Friday list should read through the loader, test first.

**The operating rule:** refuse bad data loudly at the edge, and stop before anything is calculated from it. Collect every problem in a batch, with its line and a message that says what to fix. Send the report to whoever owns the data, and never let a catch block turn an error into a default value.

![Figure 14.6. Fifteen data rows, lines 2 to 16, going through the schema rules and a vendor lookup. Eleven rows pass and become Invoice records. Four go into one report, each with its line, invoice, field and reason: line 5, INV-8304, a negative amount. Line 7, INV-8306, unknown terms. Line 11, INV-8310, an impossible date. Line 14, INV-8313, vendor V099 missing from vendors.csv.](img/error-report.png)

*Figure 14.6. Collect, then report. One pass over the batch finds every problem, and one report goes back to the owner of the data.*

**Practice it.** [Practice: errors as design](both-ai-vendors.md#practice-errors-as-design), in your own tool, fifteen minutes.
