SKIP TO CONTENT
Monospace

Output that cannot drift past its contract

A schema around every model call turns an unpredictable string into a typed value. Centralised prompts turn a scattered liability into a versioned asset. Neither is exciting, and both are what makes LLM features maintainable.

The first LLM feature in a codebase is usually a function that returns a string. The second one parses that string. By the fourth, there is a utility file with a regex in it, and somebody has written a comment apologising for it.

The root cause is treating model output as text that happens to contain data, rather than as data that happens to arrive as text. The fix is to define the contract first and refuse anything that does not satisfy it.

The schema is the interface

Every model call gets a schema describing exactly what it may return. The call is not "summarise this and return JSON" — it is a function whose return type is known at compile time and validated at runtime.

Zod is the practical choice in TypeScript because the schema is the validator and the type at once. There is no separate interface to keep in sync, which means there is no opportunity for the interface and the validation to disagree.

const Categorisation = z.object({
  category: z.enum(["income", "expense", "transfer"]),
  confidence: z.number().min(0).max(1),
  reasoning: z.string().max(280),
});

// The return type is inferred, and anything that does not
// satisfy the schema never reaches calling code.
type Categorisation = z.infer<typeof Categorisation>;

What a schema actually prevents

The obvious benefit is catching malformed JSON. The more valuable one is catching well-formed output that is subtly wrong — a category the product does not have, a confidence of 1.4, a reasoning field containing three paragraphs where the UI has room for one line.

These are the failures that reach production, because they parse. A field that is a string when it should be a number gets caught in testing. A string containing a plausible but non-existent enum value gets caught by a customer.

  • Enums constrain the model to values the product actually handles.
  • Numeric bounds catch confidence values that drifted outside their range.
  • Length limits keep generated text inside the space the interface reserved for it.
  • Refusing extra keys stops a model-invented field from silently entering the data layer.

Retry on violation, not on vibes

A schema violation is recoverable information, not just an error. The parse failure describes precisely what was wrong, and that description can go back to the model as a repair instruction.

Two structured retries resolve the large majority of violations. Beyond that the failure is usually not formatting — it is that the request was ambiguous or the schema does not match what the task actually produces. Retrying a third time mostly buys latency.

Which is why the retry budget belongs in configuration and the violations belong in telemetry. A schema that fails often is telling you something about the prompt or the product, and it is worth hearing.

Centralise the prompts

The second half of the discipline is treating prompts as versioned artifacts rather than string literals scattered across the call sites that need them.

A prompt in a module, exported by name, with its schema next to it, can be reviewed in a pull request, changed deliberately, and correlated with the outputs it produced. A prompt inlined in a component cannot be found when the behaviour it controls needs to change.

  • One module per capability, exporting the prompt and its schema together.
  • A version identifier recorded with every output, so a behaviour change can be traced to the prompt change that caused it.
  • No string interpolation of user input into instructions — user content goes in a separate, clearly delimited message.

Multi-model falls out of this

Once every call is defined by a schema rather than by a provider's response shape, the provider becomes a routing decision. The same capability can run against OpenAI, Anthropic, or Google, and the calling code does not change because the contract did not.

That is what makes model choice a configuration concern instead of a migration. It also makes per-call cost attribution straightforward: the orchestration layer already sits between the product and the provider, so it is the natural place to meter.

None of this is about making the model better. It is about making the boundary around the model tight enough that the rest of the system can be ordinary software.