ADR-0022: Generate plain Zod for clients
ADR-0022: Generate plain Zod for clients
- Status: Accepted
- Date: 2026-07-31
- Supersedes: —
- Superseded by: —
Context
ADR-0008 makes the Drizzle schema the single source of truth, and packages/shared derived its Zod schemas from it with drizzle-zod. That is what stops the two clients drifting from the database, and it worked.
WI-023 built the Expo scaffold and found the cost. Importing one runtime value from @tabletop/shared — slugSchema, a regex — grew the mobile bundle from 1088KB to 1748KB. Drizzle’s pg-core and drizzle-zod were in the client bundle along with the full table definitions for every table in the database.
Invisible until then for two reasons:
import typeis erased before bundling. Type-only consumption costs nothing, so nothing revealed it until a client needed an actual value — which every form will.- The existing guard could not catch it.
packages/sharedhad a test named “is importable without a database driver” that checked itsdependenciesexcludedpostgresandpg. Both were absent. What it depended on was@tabletop/db, whose index re-exportedclient.ts, anddrizzle-orm. The test passed and the ORM shipped anyway.
Decision
Generate the client contract. packages/shared/src/derived.ts still builds schemas from the Drizzle tables with drizzle-zod and remains the source of truth. scripts/generate-schemas.ts serialises them into src/generated.ts, which imports nothing but zod, and src/index.ts exports that. Only the generator and its tests touch drizzle, which moves to devDependencies.
Taken together with the cheap part of option A: packages/db gains subpath exports so the contract imports @tabletop/db/schema rather than the index, keeping the client factory, searchGames and seedCatalogue out of the graph entirely.
Measurements
| Bundle | ||
|---|---|---|
| Before | 1748KB | contract carried pg-core, drizzle-zod, every table definition |
| Subpath export alone (option A) | 1651KB | −97KB; measured during the spike, not sufficient on its own |
| After (this decision) | 1484KB | −264KB; no drizzle reaches a client |
| No runtime contract import at all | 1088KB | reference point only |
A correction worth recording: the proposal estimated this would reach “~1.1MB, at the floor”. That was wrong. The 1088KB figure was measured with no contract import, so it excluded Zod itself. Zod accounts for roughly 396KB and every option on the table paid it — B and C would have landed in the same place. The drizzle-specific waste was 264KB, and that is what has gone.
zod/mini could reduce the remaining 396KB. Not pursued here; it is a separate change with its own API differences.
Why not the alternatives
B — split shared into hand-written client schemas and derived server ones. Same bundle outcome, but the client half is hand-written, which is exactly the drift ADR-0008 exists to prevent. It replaces a generated artefact with a human obligation on every new field.
A alone — subpath exports. Measured at 97KB of the 660KB. Worth doing, and done, but not an answer.
D — accept it. The number only goes up, and mobile bundle size is hard to claw back once shipped.
Consequences
src/generated.tsis committed and reviewed. Diff noise on schema changes is real, and it is the point: the contract clients receive is visible in the PR that changes it.- Two independent guards, because they fail differently.
scripts/check-generated.tsruns the generator in memory and compares, reporting the first differing line. It proves the committed file is current.tests/equivalence.test.tsasserts the generated and drizzle-derived schemas accept and reject the same inputs, across hand-built mutations and all 333 fixture games. It proves the serialiser is correct — a different question, and the one that fails silently. A serialiser that dropped every.min()would regenerate identically for ever and pass the diff check.
- The generator throws on anything it does not recognise rather than emitting something plausible. A new column type fails the build until the serialiser is taught about it.
- The old “no database driver” test is replaced by one that reads the generated source and asserts it imports only
zod, plus one assertingsrc/index.tsreaches nothing else. Both would have caught the original problem; the one they replaced would not. apps/mobile’s bundle budget drops to 1.6MB, so the saving cannot quietly erode.