Catching Zod/Drizzle Schema Drift at Compile Time
Published: 2026-07-17
This post builds the compile-time drift check and proves it fires. A follow-up, How Far to Trust a Zod/Drizzle Type-Equality Check, asks how far you can trust it and what demanding exact equality costs.
Two files in my project describe the same data. One is a Drizzle table in packages/db, the shape of a row the way Postgres stores it. The other is a hand-written Zod schema in packages/shared, the shape of that same record the way my domain and API layer model it. They agreed the day I wrote them. Nothing kept them that way.
That’s the quiet failure mode of hand-written validators. Rename a column, change a field from text to integer, and the Zod schema keeps compiling, keeps validating, and keeps being wrong. TypeScript won’t say a word, because as far as it knows the two files are unrelated.
In this article I’ll walk through the check I added so TypeScript would say something. It’s a few lines of type-level code that turn silent drift into a failed build, and fitting them in meant working around one constraint: the package holding the contract can’t see the database at all.
Four packages come up below, so here they are up front:
packages/
shared/ the contract the API and the client both speak. Hand-written Zod. Imports nothing.
db/ the Drizzle tables. How a row is actually stored.
infrastructure/ the only package allowed to import db/.
core/ domain types, where the "break the build if the type breaks" convention already lived.
The whole problem is in the first two lines: shared/ describes the data, db/ describes the same data, and shared/ may not import db/.
Why I didn’t generate the schemas from Drizzle
If you’ve wired Zod and Drizzle together, you know the usual answer here, and it isn’t “write a drift check.” It’s “don’t hand-write the schema at all.” Drizzle ships a plugin for that. From the docs: “drizzle-zod is a plugin for Drizzle ORM that allows you to generate Zod schemas from Drizzle ORM schemas.” You call createSelectSchema(customers), it reads the table, and hands back a Zod schema that matches by construction. Nothing to keep in sync, because there’s one source.
My first pass reached for exactly that, without thinking about it much. Then one question stopped me: it’s convenient, but does it fit the architecture I’m aiming for? Two reasons said no.
Generating the validator from the table means the validator depends on the table. Import it into the application layer, then the API, then the client, and the storage schema is suddenly upstream of everything. That’s backwards. The domain contract should sit above infrastructure, not be dictated by how a row happens to be stored today, and drizzle-zod inverts that direction with every import.
The second reason is the bundle. A generated schema isn’t free-standing: it’s built by reading the table definition, so it holds a live reference to that table, and through it to drizzle-orm’s column machinery. That’s a dependency the schema actually uses, not dead weight a bundler can shake out, so importing it into client code to validate a form pulls the ORM runtime into the browser bundle, for users who never issue a query. I never shipped that, so it’s a cost I read about rather than measured.
So I wrote the schemas by hand instead. That keeps the layering clean and the client lean, and it costs me the one thing codegen gave for free: the guarantee that the two shapes still match. The rest of this post is how I get that guarantee back by hand.
The constraint: the contract can’t see the database
One rule in this codebase ruled out the obvious check. packages/shared, where the hand-written schemas live, may not import packages/db, where the Drizzle tables live. Not “shouldn’t.” The dependency isn’t there, and it isn’t going to be.
It’s the same layering principle, this time enforced structurally. packages/shared is the contract that the API and the client both speak. If it could reach into packages/db, the storage shape would leak into the contract through the back door, and every reason I hand-wrote the schemas would be undone. So the wall stays up.
Which leaves the puzzle. A drift check has to compare two types, the Zod contract and the Drizzle row, and they sit on opposite sides of a wall that nothing may cross. No single file is allowed to import both.
Splitting the check across the layer boundary
The way out is that the check is really two things, and only one of them needs to see both types.
The first is generic and knows nothing about my domain: a type-level equality test that asks, given two types, whether this one is exactly that one. It depends on nothing, so it lives in packages/shared with zero imports.
// packages/shared/if-equals.ts
export type IfEquals<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
export type Expect<T extends true> = T;
IfEquals<A, B> resolves to the literal type true when A and B are exactly equal, and false otherwise. It isn’t my invention; it’s the well-known Equal<X, Y> type-equality trick the type-challenges collection popularised. Why it’s written with those nested generic functions is a rabbit hole I go down in the follow-up; for now, treat it as a black box that answers one question: are these two types exactly identical?
Expect<T extends true> is the assertion: it accepts its argument only when that argument is true, so Expect<IfEquals<A, B>> compiles when the two types match and refuses when they don’t. It’s the same “build breaks if the type breaks” convention the codebase already used in packages/core/domain/types.brand-check.ts (this project’s name for a type-only file that exists to fail the build when an invariant breaks; nothing here uses branded types).
The second piece is concrete, and it’s the one that needs both types. It names CustomerResponse, which is z.infer<typeof customerResponseSchema>, the type of my hand-written Zod schema, and customers.$inferSelect, Drizzle’s inferred type for an in-memory row of the customers table. Because it imports from packages/db, it can’t live in packages/shared. It lives in packages/infrastructure, the one package the architecture allows to touch the database.
// packages/infrastructure/shared-contract.brand-check.ts
type _CustomerContractMatchesDrizzleRow = Expect<
IfEquals<CustomerResponse, typeof customers.$inferSelect>
>;
type _SubscriptionContractMatchesDrizzleRow = Expect<
IfEquals<SubscriptionResponse, Omit<typeof subscriptions.$inferSelect, 'pausedAt'>>
>;
The generic tool crosses no boundaries, because it names no types; the concrete assertion sits in the only place allowed to see both sides. (The Omit tells the check about one storage-only column the contract doesn’t expose.) The wall stays up, and the check runs on nothing more than being compiled: nobody imports these two aliases, but tsc checks every file in the package’s include globs, and evaluating Expect<IfEquals<…>> is enough to trip the constraint.
Breaking it on purpose
A check that fails the build is only worth something if it fails when it should, since a check that can never go red looks exactly like one that’s passing. So I broke it twice before trusting it.
First I inverted IfEquals, swapping its true and false branches so that equal types would resolve to false. If the assertion had any teeth, pnpm build should stop. It did, at once, on the brand-check file. I reverted.
Then I broke what the check is there to catch. I deleted the email field from the customer Zod schema and left the Drizzle table alone, exactly the drift I was worried about. The build failed, and pointed here:
shared-contract.brand-check.ts(19,3): error TS2344: Type 'false' does not satisfy the constraint 'true'.
That’s the mechanism in one line. IfEquals saw the shapes disagree and resolved to false; Expect<false> broke its T extends true constraint. One honesty note: the error names the assertion, shared-contract.brand-check.ts(19,3), not the email field I removed elsewhere. Whole-type equality tells you that the contract and the row stopped matching, not which field moved, so on a wide schema you still diff them by hand. I put the field back.
But a manual break is a one-time check. So IfEquals ships with a small regression test beside it, built from fabricated types:
// packages/shared/if-equals.brand-check.ts
type _FieldTypeMismatchIsNotEqual = Expect<
// @ts-expect-error - a `number` field must not be structurally equal to a `string` field
IfEquals<{ a: string }, { a: number }>
>;
The @ts-expect-error flips the polarity: that line is supposed to be an error. If a change ever made IfEquals wrongly call a string field and a number field equal, the error would vanish, and @ts-expect-error would turn into the build failure itself.
Conclusion
The check is maybe a dozen lines: a generic equality type and an Expect in packages/shared, two assertions in packages/infrastructure. It runs nothing at runtime and generates nothing. Rename a column, drop a field, change text to integer, and the build stops at the assertion that no longer holds, the same as any other type error. The schemas stayed hand-written, the client bundle stayed lean, and the two shapes can’t drift apart without the compiler saying so.
That’s the core of the pattern, and it’s ready to adopt. But I glossed over two things. I called IfEquals exact without showing why that strange nested-function shape gives you identity instead of the usual assignability. And I demanded exact equality without counting the cost: the check fires on any mismatch, so a Date column against a hand-written string trips it as surely as real drift does, and lining the two shapes up is work you take on. How far to trust the check, and what that exactness costs, is what the follow-up is about.
Further Reading
Equal<X, Y>in type-challenges — the canonical form of the generic-function type-equality trick.IfEqualsis just my name for it; the follow-up unpacks why it works.- Type Safety with Drizzle and Full-Stack Zod: Keeping Your Client Bundle Clean — the same trick applied to Drizzle, and where the client-bundle cost behind my second reason is measured rather than assumed. Read it as the version without the layering split: it keeps the check inline rather than pushing the generic half down into a dependency-free package.
- Widening a Drizzle Database Type to Test Against Local Postgres — the same project, the same week, a different Drizzle typing problem. There a derived type was too narrow; here two hand-written types had to be pinned together.