A schema that fails loudly beats a blob that fails silently
The single cheapest change to an extraction pipeline, and why free-text output is a decision to discover your bugs from customers.
The cheapest improvement available to most extraction pipelines is not a better model or a better prompt. It is refusing to accept output that does not fit a shape you declared in advance.
The failure this prevents
A pipeline asks a model to extract an invoice and gets back free text, or JSON
with no enforced shape. Downstream code reads result.total and gets
undefined, or "1,240.50" where it expected a number, or null because the
model hedged.
None of that throws. It writes. The bad value lands in the database looking exactly like a good one, and surfaces weeks later when a customer disputes an amount.
Without a schema, a malformed extraction and a correct extraction are the same kind of object. That is the entire bug.
What the schema has to do
Declaring types is the easy half. The half that matters:
// Not this — every field optional, nothing enforced,
// undefined sails straight through into the ledger.
type Invoice = {
total?: number
currency?: string
lineItems?: unknown[]
}
// This. Required fields are required. Values are constrained.
// Confidence rides along with the value it describes.
type Invoice = {
total: Money // { amount: number; currency: ISO4217 }
issuedOn: PlainDate // not a string that might be MM/DD or DD/MM
lineItems: LineItem[] // must be non-empty
confidence: Record<keyof Invoice, number>
source: { documentHash: string; page: number }
}
Three things are doing work there.
Money rather than number. A bare number invites a currency bug that is
invisible until an invoice arrives in euros. Making currency non-optional means
the extractor has to have an answer.
PlainDate rather than string. 03/04/2026 is two different dates
depending on which side of the Atlantic produced it. A parsed date type forces
that ambiguity to be resolved at the boundary, where you still have the document
to look at.
confidence keyed to the field names. Confidence for the whole document is
nearly useless — a document is rarely uniformly good. Per-field confidence is
what lets you accept the date and question the total.
Then make it actually fail
A schema that logs a warning and carries on is a schema-shaped decoration. The validation has to have somewhere to send failures:
- Validate at the boundary, immediately after extraction, before anything downstream sees the object.
- Route failures to a queue, not to the table of record. A person resolves them.
- Keep the raw output alongside the failure. When you are debugging why the extractor produced nonsense, the nonsense is the evidence.
The one that catches the most
Add cross-field arithmetic as part of validation:
const summed = invoice.lineItems.reduce((n, li) => n + li.amount, 0)
if (!closeEnough(summed + invoice.tax, invoice.total)) {
return review(invoice, "line items do not sum to total")
}
This single check catches a large share of numeric misreads, and it costs nothing — no extra model call, no latency. A hallucinated figure almost never happens to satisfy the arithmetic, so the constraint does the detection for you.
Most pipelines we look at have no version of this, and it is consistently the highest-value thing we add.
More on this in when extraction accuracy collapses and how we build extraction.