Queries
Return $inferSelect row types straight from the table. (Next chapter.)
The architectural principle that makes your Drizzle schema the one source every downstream type, validator, and form is generated from.
You’re building the invoices feature. An invoice has an id, a total, a status, a dueDate, and an organizationId. Over the next hour you write that shape down five times: the Drizzle table that stores it, the type Invoice your Server Component renders, the Zod schema validating the form, the Server Action that saves it, and the field list the form draws. Five spellings of the same columns, and it all works.
Then someone renames a column, and four of the five fall silently out of sync.
This lesson is about which of those five spellings is canonical: why the other four must be generated from it rather than retyped, and what breaks when someone forgets. The db/schema.ts you build over the next several lessons is the root every later part of your app derives from, from queries to validators to forms to security policies. Get it right and a schema change ripples outward on its own.
Take one invoice row and watch where its shape shows up in a real codebase. Each surface needs the same columns, but each needs them for a different reason.
idtotalstatusdueDateorganizationId You already pass typed props into Server and Client Components, so two of these are familiar. The Zod validator, the Server Action, and the form come later. The problem is the same across all five: each has to agree on what an invoice’s columns are.
Every codebase resolves that one way or another, whether it notices or not.
total: string in the Drizzle table, total: string in the Invoice type, total in the Zod schema, and so on. Five independent declarations that happen to match.Option A is the default, because it’s what typing feels like: you need a type, so you write one. With shapes this short, it even looks DRY enough. Let’s resolve the fork by watching Option A break.
Picture a routine rename. The product team decides total is the wrong name for an invoice’s headline number: the customer still owes it, so it should be amountDue. Someone does the work properly, writing the migration, running it against Postgres, and renaming the column in the Drizzle schema. The database and the schema now agree.
Now watch the four other spellings, the ones hand-copied under Option A, and notice when each one breaks.
total aligned total aligned total aligned total aligned total aligned Day one: everything agrees. All five spellings name the same column, total, and the feature works.
amountDue aligned total aligned total aligned total aligned total aligned The rename lands where it should. The migration and the Drizzle schema turn total into amountDue, and the database and the schema agree.
amountDue aligned total TypeScript is happy total TypeScript is happy total TypeScript is happy total TypeScript is happy Four shapes drift, silently. The four hand-copied surfaces still name total, and because none is linked to the schema, TypeScript reports nothing.
amountDue aligned total runtime 500 total runtime 500 total runtime 500 total runtime 500 500 — column "total" does not exist surfaced at 3am, not in review Production, first insert. The next deploy ships, and the first saved invoice references a column that no longer exists, so production throws a 500.
amountDue aligned total compile error total compile error total compile error total compile error The world where the four were generated. Same rename, but now each shape is generated from the schema, so all four failures become red compile errors the instant you save.
TypeScript stays silent because each hand-copied shape is an unconnected island. The schema has exactly one typed link to the app, at db.insert(invoices), but the payload reaches it through untyped hops: form string, hand-written Zod, plain data. Nothing along that path is wrong in a way the compiler can see, so the mismatch can only fail where the typed link finally bites, at runtime on the insert. This is the failure mode: hand-typed restatements drift silently. Generate those four shapes from db/schema.ts instead, and the rename turns every stale .total into a red squiggle at edit time, four two-minute fixes that never reach a deploy.
With that failure in view, here’s the rule.
A source of truth is the one place a fact is defined; everything else reads from it instead of holding a copy that can disagree. For your data shapes, that place is the schema. Here’s what gets generated from it, and by which tool.
invoices.$inferSelect .invoices.$inferInsert .createInsertSchema / createSelectSchema, which reads the table and emits a matching schema (you’ll build this with forms and validation).Picture a tree: db/schema.ts is the root, and every typed shape above is a branch generated from it, not a hand-copied parallel beside it. Edit the root and the change flows down every branch; hand-edit a branch and you’ve forked it, which the type checker catches as drift.
invoices table idtotalstatusdueDateorganizationId One source, five derivations. Rename a column in the root and every consumer that still names the old one becomes a compile error.
One fact, one file: change it there and every downstream type checker catches the drift for you.
Two shapes you still author by hand. Both stay honest by staying anchored to the schema rather than floating free of it.
Carve-out 1: external API DTOs. When you expose a public API, say a GET /api/invoices that other people’s code calls, the response is a deliberately different contract from your row, usually narrower: you hide internal columns like internalNotes and drop tenancy fields like organizationId. Because it’s intentionally not the row, it correctly doesn’t derive from it. That’s a DTO , the shape of data as it crosses a boundary, distinct on purpose from how you store it.
Carve-out 2: derived view shapes. Sometimes you need a projection of the row, like a dashboard summary or a list row lighter than the full invoice. That’s a real, distinct shape, but you compose it from inferred pieces rather than retyping field names.
type InvoiceSummary = Pick<Invoice, 'id' | 'status' | 'amountDue'> & { organizationName: Organization['name'];};InvoiceSummary names which fields it wants but never declares their types: Pick reads those off the inferred Invoice, and Organization['name'] reads the type off the inferred Organization. Rename amountDue in the schema and this summary breaks at compile time too, because it points at the schema rather than paraphrasing it.
So the test for a legitimate carve-out versus drift is: does the shape restate a field name and its type the schema already knows? Spell out total: string by hand and that’s a fork waiting to drift. Project or narrow inferred members with Pick, Omit, &, or Type['field'] indexed access, and it stays anchored. The carve-out is permission to compose a new shape from the inferred ones, not to write a type from scratch.
This principle dictates the order of your moves when a column changes. Follow it and the type checker works for you.
Change the column in db/schema.ts first. Edit the source before any type, query, or form.
Generate the migration from the schema. Drizzle Kit reads the changed file and produces the SQL migration. (You’ll set up Drizzle Kit in a later chapter.)
Run the type checker. It surfaces every consumer of the changed shape as a compile error: queries, props, derived summaries, all of it.
Fix each error the compiler points you at, then ship. Most are mechanical renames, and the squiggle lands you on each one.
Editing the source first is what unlocks this. Patch a query or a hand-typed interface first and the compiler can’t help: the source moved last, so you’re back to recalling every place that touched the column, and missing one. Edit the source first and every consumer is a branch of the file you just changed, so the compiler hands you the exhaustive list to follow.
Most of Principle #2’s payoff is downstream, in chapters you haven’t reached. The db/schema.ts you write next is the root all of it hangs off.
Queries
Return $inferSelect row types straight from the table. (Next chapter.)
Zod validators
Drizzle’s Zod generation turns the schema into runtime validators. (When you reach forms and validation.)
Server Actions
Parse incoming input with that generated Zod before touching the database. (Same.)
Forms
Read their field names off the same Zod schema. (Same.)
RLS policies
Reference the same column names from the same file. (Much later, with multi-tenancy and security.)
So a hand-typed row interface isn’t a style nit: it cuts the root off from one of those branches, and every guarantee the branch provided goes with it.
Three moves break the principle in practice. Learn each by name, because these are what to flag the moment you see them in review.
Hand-typed row interfaces that go stale unnoticed. The main one. A type Invoice = { … } written out by hand, anywhere, signals that Principle #2 got skipped, and it’s exactly what drifted in our four-way failure. (Later this chapter, $inferSelect becomes the one-line replacement that erases the category.)
as any to bridge a stale type onto a new schema. When a hand-typed shape throws after a schema change, that error is the one signal that drift happened. as any doesn’t fix the drift; it silences the warning and ships the bug, now harder to find because the compiler has stopped looking.
Copying a Zod schema’s field list off the Drizzle table by hand. This feels responsible, but transcribing the fields instead of generating them with createInsertSchema / createSelectSchema recreates the drift one layer over: the two lists agree today and silently diverge the next time someone edits the schema.
Source of truth, or drift in disguise? Sort each shape into how it should come to exist. Drag each item into the bucket it belongs to, then press Check.
type Invoice you typed out by hand in lib/types.tstype Invoice = typeof invoices.$inferSelectcreateInsertSchema(invoices)Partial<NewInvoice> for a patch payload, where NewInvoice is the inferred insert type/api/invoices response, intentionally narrower than the stored rowtype InvoiceSummary = Pick<Invoice, 'id' | 'amountDue'>The test isn’t whether a type keyword is in play; it’s whether the shape restates what the schema knows or composes from it. $inferSelect, createInsertSchema, Pick<Invoice, …>, and Partial<NewInvoice> are anchored to the source. A hand-typed type Invoice and a hand-copied Zod field list are forks. Same keyword, opposite verdicts.
One last check on the failure mode itself, since the silence is the part beginners underestimate.
A column is renamed in db/schema.ts and the migration runs cleanly against Postgres. Elsewhere, a hand-typed type Invoice in lib/types.ts still names the old column. You change nothing else and ship. What happens?
type Invoice line — once the schema changed, TypeScript flags the interface as out of date.type Invoice is an island — there’s no link from it back to the schema, so renaming a column there can’t turn the interface into an error. That’s exactly why the drift is silent and surfaces later as a production 500. Had the row type been generated (typeof invoices.$inferSelect), this same rename would have lit up every .total reference as a red squiggle the instant you saved the schema.Next, you build the file the whole tree hangs off.