How Far to Trust a Zod/Drizzle Type-Equality Check
Published: 2026-07-17
In an earlier post I put a compile-time drift check between a hand-written Zod contract and a Drizzle row. The load-bearing piece is a type called IfEquals, wrapped in Expect<T extends true>, that fails the build when the two shapes stop matching. I showed it works by breaking it, and I left two things unexplained on purpose.
First, I called IfEquals exact but never justified its shape, those nested generic functions that look like a typo. Second, I proved the check fires without asking how far you can actually trust it, or what it costs to demand that two types be exactly equal. This post answers both, in that order. If you haven’t read that one, the thing to carry over is that IfEquals<A, B> is meant to resolve to true only when A and B are identical, and Expect refuses to compile anything that isn’t true.
Why not the naive equality check
First, the shape of IfEquals. Here’s the version most people write first:
type NaiveEquals<A, B> = A extends B ? (B extends A ? true : false) : false;
Read it aloud and it sounds right: A equals B when each extends the other. For plenty of types it agrees with real equality, including the drift this check cares about: NaiveEquals<{ a: string }, { a: number }> comes back a clean false.
The trouble is that extends tests assignability, not identity, and that gap shows up around any, readonly, and types that are mutually assignable without being the same. The TypeScript handbook names the most familiar case: “When conditional types act on a generic type, they become distributive when given a union type.” Distribution can hand back a muddled answer instead of a clean verdict. (It’s the famous hole, though not, as it turns out, the one that could bite this check; I come back to that when I stress-test IfEquals.)
Rather than audit which of these could bite my comparison, I used the form the community settled on for exact equality:
// 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;
Wrapping each side in <T>() => T extends A ? 1 : 2 puts the type parameter inside a function signature. T is unbound, so the compiler can’t evaluate the conditional; it decides whether the two function types match by comparing their bodies, which differ only in A versus B. It treats them as the same type when A and B are the same, a far tighter bar than the assignability extends normally tests. It’s what the type-challenges collection ships as Equal<X, Y>; IfEquals is my name for it, and Expect is the separate assertion I pair with it. It’s a strong stand-in for identity, not a proof of one.
How much can I trust IfEquals?
IfEquals has known edge cases, and for a drift check only one failure actually hurts: IfEquals returning true for two types that really differ, which Expect would pass straight through as a green build.
The doubt I’d heard about was distribution, so I checked it instead of assuming. I put both versions in a scratch file and pointed them at a union:
type N = NaiveEquals<string | number, string>; // boolean
type I = IfEquals<string | number, string>; // false
NaiveEquals comes back boolean rather than a verdict. Substituting the types in shows why: the conditional distributes over the union, running once per member and unioning the results. string extends string gives true, number extends string gives false, and true | false is exactly what boolean is. So boolean here isn’t a verdict of “maybe”; it’s two disagreeing answers stacked. IfEquals returns a clean false, because wrapping each side in a function signature leaves the outer conditional checking a function type, so nothing distributes.
But that whole failure can’t actually reach this check: distribution only fires when a bare type parameter meets a union, and I’m always comparing object types, which never distribute. The one that can bite an object comparison is a field typed any: NaiveEquals<{ a: any }, { a: string }> returns true, a false green, while IfEquals returns false. That, not the union, is why IfEquals is the right tool here.
What keeps me honest either way is the Expect wrapper. IfEquals is never called bare; anything that isn’t exactly true, a false or a muddled boolean alike, fails T extends true, so a wrong-but-not-true answer can’t become a green build. A false true is the one thing Expect can’t catch: it fails open, straight to a green build. For a field added, dropped, or retyped IfEquals reports inequality, and the fabricated-types regression test from the earlier post pins the retyped case directly, a string field against a number field. What I can’t promise is that no exotic type I’ve never hit makes IfEquals return a false true. There I’m trusting the trick’s long track record, not a proof.
Where this fits, and where it doesn’t
Exact equality is a strong claim, and it’s worth being honest that it only removes half of what I objected to in drizzle-zod. The module-dependency coupling is gone: packages/shared still never imports packages/db, I still write the schema by hand, and no ORM reaches the client. The shape coupling stays. The assertion fails unless the contract type matches the row, so the contract is still pinned to the storage shape. That pinning is the point of a drift check, not something I escaped; what I gained is the right to decide where the two diverge, instead of a generator deciding for me.
And deciding costs something, because exact equality is unforgiving. z.infer and $inferSelect model the same column differently by default: a nullable column is | null, while Zod’s .optional() gives | undefined, and a timestamp is a Date, not a string. To make the assertion pass at all, the schema has to mirror those choices on purpose:
const customerResponseSchema = z.object({
id: z.string().uuid(),
email: z.string(),
canceledAt: z.date().nullable() // a nullable timestamp is Date | null, not string | undefined
// ...
});
You pay that alignment once, at build time, instead of meeting it as a runtime mismatch later. Where a contract genuinely transforms the row, renamed columns, serialized dates, computed fields, each divergence has to be written into the compared types explicitly (the earlier post’s subscription assertion does exactly this, with an Omit for one storage-only column).
Past a few, a looser check would serve better: a one-directional Expect<CustomerResponse extends typeof customers.$inferSelect ? true : false>, which asks only whether the contract still fits the row rather than whether the two are identical. That forgives a contract that is merely narrower than the row, and still catches a field added, dropped, or retyped. I chose exact equality because I wanted the two genuinely interchangeable, and because the looser check waves through drift that matters: let the row quietly gain a | null and string extends string | null still passes.
Two gaps stay open, both the same shape as the one this check closes. It only covers the pairs I remembered to assert, and each assertion only bites while its file stays in the compiled set, so a forgotten pair or an excluded file leaves the build green with nothing watching, which looks exactly like passing. That’s enrollment-by-memory again, the very thing the check was meant to replace, and it has the same fix I haven’t written yet: a test that walks the exported table definitions and fails when one has no matching, compiled assertion.
Conclusion
IfEquals is a strong stand-in for identity, not a theorem, and the Expect wrapper turns that from a worry into a fail-closed guarantee for every case but a false true, which for the drift I actually catch it doesn’t produce. Exact equality buys that certainty by pinning the contract to the row’s shape and making me spell out every deliberate divergence, and it leaves the check’s own coverage resting on the same memory it was built to replace. Worth it here, where the response mirrors the row and the pairs are few. Somewhere with a heavily transformed contract, a looser check would be the better trade.
Further Reading
- TypeScript Handbook: Conditional Types — the reference for distributive conditional types, the mechanism behind why the naive equality check misbehaves on unions.
Equal<X, Y>in type-challenges — the canonical form of the generic-function equality trickIfEqualsis named after.