# 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-10T15:33:14Z
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-10T15:33:14Z
  by: process:panaversity
trust_tier: unverified
build_id: sha256:46fd53c5d7ab1c14f6347908d45c9fdaecda4d914439923ee878bdcecd57803e
dirty: true
ksor_version: 0.0.60
---

**In everyday life.** A good clerk who finds problems in a batch of 200 invoices does not stop at the first. She marks every problem with a 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.** On a run nobody watches, 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. Code that expects a failure **catches** it, and decides what to do.

| | TypeScript | Python |
| --- | --- | --- |
| Raise an exception | `throw` | `raise` |
| Catch one | `try` and `catch` | `try` and `except` |

Design these choices from the start:

| 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, [until credits are decided](#what-does-refusing-cost) |
| Never fail silently | Do not turn an error into a default value | `invoiceFromRow` turns a blank amount into $0.00 ([14.4](data-models-and-schemas.md#does-the-library-change-values-for-me)) |
| Collect, do not stop at the first | In a batch, check every record, then report them all | All four bad rows in one report |
| 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.

### 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: a negative amount on line 5, terms "Net 10" on line 7, the date `2026-13-02` on line 11, and on line 14 a vendor id, `V099`, that 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.

Here is the loop. It works as the clerk does: it writes a note for every problem in a row, and only then decides where the row goes. `candidate(row)` is the [controlled conversion](data-models-and-schemas.md#does-the-library-change-values-for-me) of 14.4. `vendors` is the `VendorDirectory` of [14.3](classes-and-objects.md), and its full version adds `has`, which asks whether it knows an id. Predict: a row has a negative amount and a vendor id that is not in the list. How many problems does it get, and which list does it go in, `valid` or `errors`?

```ts tab="TypeScript" tab-group="language"
// for each row of the file
readRows(csv).forEach((row, i) => {
  const line = i + 2; // line 1 is the header
  const parsed = InvoiceSchema.safeParse(candidate(row));
  const problems: string[] = []; // this row's notes

  // one note for each problem the schema found, with the field's name
  if (!parsed.success) {
    for (const issue of parsed.error.issues) {
      problems.push(`${issue.path.join(".")} ${issue.message}`);
    }
  }

  // one more note if the vendor id is not in the list
  if (!vendors.has(row.vendor_id)) {
    problems.push(`vendorId ${row.vendor_id} is not in vendors.csv`);
  }

  // no notes: the row is good. Any note: it goes back, with all its notes
  if (parsed.success && problems.length === 0) {
    valid.push(parsed.data);
  } else {
    errors.push({ line, invoice: row.invoice_number, problems });
  }
});
```

```python tab="Python" tab-group="language"
# for each row of the file
for i, row in enumerate(read_rows(csv)):
    line = i + 2  # line 1 is the header
    problems: list[str] = []  # this row's notes

    # one note for each problem the schema found, with the field's name
    try:
        model = InvoiceModel.model_validate(candidate(row))
    except ValidationError as err:
        for e in err.errors():
            problems.append(f"{e['loc'][0]} {e['msg']}")

    # one more note if the vendor id is not in the list
    if not vendors.has(row["vendor_id"]):
        problems.append(f"vendor_id {row['vendor_id']} is not in vendors.csv")

    # any note: the row goes back, with all its notes. No notes: it is good
    if problems:
        errors.append({"line": line, "invoice": row["invoice_number"], "problems": problems})
    else:
        valid.append(model)
```

| Language | The marks in this loop |
| --- | --- |
| TypeScript | `forEach` runs the lines once for each row, and `i` counts the rows from 0. `!` means not, and `push` adds an item to the end of a list. `path.join(".")` turns a field's path into one piece of text. |
| Python | `enumerate` counts the rows from 0, in `i`. `append` adds an item to the end of a list. `as err` names the error it caught, and `e['loc'][0]` is the field's name. |

Two problems, and the row goes in `errors`. The vendor check still runs, because nothing here stops at the first problem.

On the messy file, the loader prints:

```text tab="TypeScript" tab-group="language"
$ 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
```

```text tab="Python" tab-group="language"
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. Each line names the row, the invoice, the field and what is allowed, so the owner fixes all four at once, with no questions.

### When is a catch block wrong?

When it catches every error and continues, because that hides bugs. When you see one in agent-written code, 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.** A TypeScript `catch` catches everything. 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 price is worth paying only when the rules are right: [Marcus's run](overview.md#why-it-matters) stopped for a credit that was never a mistake. Ana's rule assumes this export holds no credits, and if it ever does, the rule must change first. A run also need not stop whole: it can pay the valid rows and set the bad ones aside for their owner. Which is right depends on what a bad row would cost, so decide it in your brief.

### Try it

The file in `14-5` checks three rows and collects every problem. Run it and investigate, as in [13.1](../programming-fundamentals/you-read-the-agent-writes.md#try-it).

**Modify.** Send: "Please change the loop so it stops at the first problem. Don't run it yet." How many lines will it print now? Which problem disappears? Then ask the agent to run it.

**Make.** Decide what the person who owns the export needs to read. Then send: "Please put the loop back so it collects every problem, and end each message with what to fix: [your words]. Don't run it yet." Read the messages, and then ask the agent to run it.

**What you should see.** After your change, one problem and a count of `1`. The bad terms on INV-8306 are never reported, so its owner would have to come back a second time.

**The operating rule:** refuse bad data loudly at the edge, before anything is calculated from it. Collect every problem in one report for whoever owns the data, each with its line and what to fix. 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.*

**Next.** [14.6](json-and-rest-apis.md) gets the data from another program, which can change without telling you.
