Schema rules that Zod can't express — cross-field checks, conditional requirements, numeric consistency. Each invariant you add tightens the contract and compounds correctness.
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.
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"
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"
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.
The model returns:
{ "start": "2024-03-15", "end": "2024-03-20" }
No generic "try again" — the model knew exactly what to fix.
A single invariant does three things at once:
Catches the specific violation on every response, every time.
The violation string becomes the exact feedback to the model.
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
}
}
You don't design invariants up front. You discover them from production failures.
Ship with the schema
Start with just Zod validation. No invariants needed yet.
Observe failures via onAttempt
Watch what passes schema validation but still isn't right.
Notice a pattern
A field relationship the schema can't enforce — dates reversed, math not adding up, conflicting values.
Add one invariant
That structural failure is now caught and repaired automatically.
Each invariant tightens the schema contract. Strictness compounds.
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.