Invariants

Schema rules that Zod can't express — cross-field checks, conditional requirements, numeric consistency. Each invariant you add tightens the contract and compounds correctness.

What are invariants

Zod validates types, fields, and enums. Invariants validate the relationships between them. They're functions that run on the parsed, typed data and return true if it passes, or a string describing the violation.

That string matters — it becomes the exact feedback the model receives on retry.

invariants: [
  (data) => data.items.length > 0 || "items must not be empty",
  (data) => data.end > data.start || "end must be after start",
  (data) => allowedRegions.includes(data.region)
    || `region ${data.region} not in allowed list`,
]

Each invariant is a function on the parsed data — no external dependencies, no side effects.

How to write them

An invariant is a function that takes the fully parsed, typed data and returns true or a violation string. Keep them simple and focused — one check per invariant.

Date ordering

(d) => d.end > d.start
  || "end must be after start"

Math consistency

(d) => Math.abs(d.subtotal + d.tax - d.total) < 0.01
  || `subtotal + tax != total`

Conditional requirement

(d) => d.action !== "block" || d.confidence > 0.7
  || "can't block with low confidence"

Non-empty collection

(d) => d.tags.length > 0
  || "must have at least one tag"

How invariant repair works

When an invariant fails, the exact violation string becomes the repair message. The model gets targeted constraint feedback instead of generic "try again."

Invariant

(data) => data.end > data.start || "end must be after start"
Attempt 1 — FAIL

The model returns:

{ "start": "2024-03-15", "end": "2024-03-10" }

The invariant fires. The model receives:

Your response had valid types but violated schema constraints:

- end must be after start

Please correct these issues and respond with valid JSON only.

Attempt 2 — PASS

The model returns:

{ "start": "2024-03-15", "end": "2024-03-20" }

No generic "try again" — the model knew exactly what to fix.

What each invariant gives you

A single invariant does three things at once:

1

A runtime guard

Catches the specific violation on every response, every time.

2

A repair directive

The violation string becomes the exact feedback to the model.

3

A named error class

Tags the failure so you can observe and track it over time.

That third part matters. The moment you add an invariant, you've named a failure mode. It goes from "sometimes the output is wrong" to "end-before-start fired 47 times this week." You can count it, track it over time, and measure whether a prompt change or model upgrade actually reduced it.

onAttempt: (event) => {
  if (!event.ok) {
    // event.category  — "INVARIANT_ERROR", "VALIDATION_ERROR", etc.
    // event.issues    — ["end must be after start"]
    // event.number    — which attempt
    // event.durationMS — how long it took
  }
}

Discovery workflow

You don't design invariants up front. You discover them from production failures.

1

Ship with the schema

Start with just Zod validation. No invariants needed yet.

2

Observe failures via onAttempt

Watch what passes schema validation but still isn't right.

3

Notice a pattern

A field relationship the schema can't enforce — dates reversed, math not adding up, conflicting values.

4

Add one invariant

That structural failure is now caught and repaired automatically.

Each invariant tightens the schema contract. Strictness compounds.

Works alongside prompt engineering

Invariants don't replace prompt engineering, provider structured output modes, or model upgrades. They work alongside all of them.

The difference: prompt changes are global — fixing one structural issue can destabilize other fields. A model upgrade might fix an issue, or might not. Provider features help, but don't cover cross-field logic.

An invariant is a guaranteed fix for a specific error. If the issue still occurs after a prompt change or model upgrade, the invariant catches it, repairs it automatically, and it never reaches production. If the issue stops occurring, the invariant costs nothing — it doesn't fire.

Real signal, not vibes

Because every attempt is logged, you get real data: which invariants still fire, which went silent after a prompt change, which appeared after a model upgrade. You go from "I think the prompt is better" to knowing exactly what changed.