Queries
Return $inferSelect row types straight from the table. (Next chapter.)
The architectural principle that makes your Drizzle schema the one canonical source every downstream type, validator, and form is generated from rather than hand-copied.
You’re building the invoices feature. An invoice has an id, a total, a status, a dueDate, and an organizationId: an ordinary row shape, the kind you’ll define a hundred times. Over the next hour, without thinking twice, you write that shape down five separate times. Once as the Drizzle table that stores it, once as a type Invoice for the prop your Server Component renders, once as a Zod schema validating the form, once in the Server Action that saves it, and once as the field list the form actually renders. That’s five spellings of the same handful of columns. It compiles, it ships, and everything works.
Then someone renames a column, and four of those five fall silently out of sync.
This lesson is about which one of those five is the real one. By the end you’ll know which spelling is canonical, why the other four have to be generated from it instead of retyped by hand, and what breaks the day someone forgets. This is Architectural Principle #2, and it runs through the whole chapter. The db/schema.ts file you build across the next several lessons is the thing every later part of your app derives from, including queries, validators, forms, and security policies. Get this principle right and a schema change ripples outward on its own. Get it wrong and you ship the bug above.
Let’s make the problem concrete before we name the fix. Take that one invoice row and watch where its shape shows up in a real codebase. Each surface needs to know the same columns, but each needs them for a different reason.
idtotalstatusdueDateorganizationId You’ve already met most of these surfaces, since you’ve been passing typed props into Server and Client Components for a while now. The other three, the Zod validator, the Server Action, and the form, are still ahead of you. But you already understand the shape of the problem: each of these is a place that has to agree on what an invoice’s columns are.
That leaves a real decision, one every codebase makes 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 what you fall into by default, because it’s what typing feels like: you need a type, so you write the type. It even looks DRY enough, since the shapes are short. Rather than argue the fork either way, let’s resolve it by watching Option A break.
Picture a routine rename. The product team decides total is the wrong name: an invoice’s headline number is what the customer still owes, so it should be amountDue. This is a reasonable change, a decision rather than a mistake. Someone does the work properly. They write the migration, run it against Postgres, and update the column in the Drizzle schema from total to amountDue. The database and the schema now agree, so far nothing is wrong.
Now follow what happens to the other four spellings, the ones that were hand-copied under Option A. Watch how each one fails, and when.
total aligned total aligned total aligned total aligned total aligned Day one: everything agrees. Every spelling names the same column, total. The feature works, and nothing about the picture hints that four of these five are about to become a problem.
amountDue aligned total aligned total aligned total aligned total aligned The rename lands where it should. Someone writes the migration, runs it against Postgres, and updates the column in the Drizzle schema, so total becomes amountDue. The database and the schema agree. The source of truth has moved, and so far nothing is wrong.
amountDue aligned total TypeScript is happy total TypeScript is happy total TypeScript is happy total TypeScript is happy Four shapes drift, and nobody is warned. The four hand-copied surfaces still name total. None of them is linked to the schema, so none of them complains and TypeScript reports nothing. The gap has opened silently.
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. The first time someone saves an invoice, the insert references a column that no longer exists, and production throws a 500. The drift surfaces as a late-night page, not as a comment in code review.
amountDue aligned total compile error total compile error total compile error total compile error The world where the four were generated. Same moment as step 3, but now each of the four shapes was generated from the schema rather than hand-copied. The exact same rename turns all four silent failures into red compile errors the instant you save the file. The drift never reaches a deploy because it never survives the type checker.
Walk through what just happened, surface by surface, paying attention to when each one breaks.
First, the hand-written type Invoice = { …; total: string; … } sitting in some lib/types.ts. After the rename it’s stale, naming a column that no longer exists. But TypeScript doesn’t say a word, because nothing connects that interface to the schema. It’s a free-floating declaration that happened to match the database yesterday. Every component that reads invoice.total still compiles cleanly against a column that’s gone.
Second, the form still renders a total input and posts it, and the hand-written Zod validator still checks for a total field and waves it through. Neither knows anything changed: a form field is just a string name, and a hand-written Zod schema is just another free-floating declaration. The Server Action then receives that validated { total } payload and tries to save it. This is where a typed link would have caught the drift. When the data reaches db.insert(invoices), Drizzle’s insert is typed straight off the schema, so it knows the column is amountDue now. But the payload arrived through an untyped hop, from form string to hand-written Zod, so by the time it reaches the database it’s plain data the compiler has already stopped reasoning about. Drizzle has no total column to map it to, so it fails at runtime, not at compile time.
Then the deploy goes out, and the first time anyone saves an invoice, production throws a 500 on the insert. The point worth holding onto: the type checker said nothing the entire time. Not one red squiggle across four broken surfaces. Each hand-copied shape is an island, and the compiler can only protect you across a boundary it can actually see, so a hand-written parallel type is invisible to it. You told TypeScript total: string in five places, and renaming one of them doesn’t make the other four wrong in any way TypeScript can detect. They’re just five strings that used to agree.
That’s the failure mode, and it has a name: hand-typed restatements drift silently. Not at compile time, not anywhere you’d catch it in review, but silently, and then all at once in production.
Now run the counterfactual, since it’s what turns the failure into a rule. Suppose the four downstream shapes hadn’t been hand-typed, but were instead generated from db/schema.ts. The Invoice type was derived from the table, the Zod schema was generated from the table, and the Server Action’s argument type came from the table. In that world, the moment you rename total to amountDue in the schema, all four derived shapes update with it, and every place that still reads .total becomes a red squiggle at edit time, before you’ve even saved. The same rename that broke production becomes four compile errors you fix in two minutes. The drift never reaches a deploy because it never survives the type checker.
With that failure in view, here’s the rule.
A source of truth is exactly that: the one place a fact is defined, with everything else reading from it instead of holding a parallel copy that can disagree. For your data shapes, that place is the schema file. Here’s what gets generated from it, and by which tool. You’ll meet each generator in its own lesson, so for now just note the names and the direction they run.
invoices.$inferSelect .invoices.$inferInsert .createInsertSchema / createSelectSchema, which reads the table and emits a matching schema. (You’ll build this when you reach forms and validation.)A useful mental model here is a tree: the schema is the root of a derivation tree. One node sits at the top, db/schema.ts, and every other typed shape is a branch generated from it, never a hand-copied parallel growing beside it. Edit the root and the change propagates down every branch. Hand-edit a branch instead and you’ve created a fork that will drift. The type checker is what walks the tree and finds those forks.
invoices table idtotalstatusdueDateorganizationId One source, five derivations: the five-surfaces picture from earlier, now resolved with direction. Rename a column in the root and every branch updates with it, and every consumer that still names the old column becomes a compile error.
The payoff fits in one sentence: change one file, and every downstream layer’s type checker catches the drift for you. The codebase reshapes itself around a schema edit, because every shape that mattered was a branch of the thing you edited.
This instinct should feel familiar, because it’s the same one behind the principles you’ve already met: co-locating code by feature so a feature lives in one folder, preferring explicit wiring over hidden magic, and making impossible states unrepresentable so a bad value can’t even be spelled. What runs through all of them is that a well-built codebase keeps one place each fact lives. Principle #2 is that same instinct, pointed at your typed data shapes.
Read that principle too literally and it sounds like a command to delete every type in your codebase. It is not. There are exactly two shapes you still author by hand, and what keeps them honest is that both stay 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 database row. It’s usually narrower: you hide internal columns, drop tenancy fields, and maybe rename things for an outside audience. Your row has internalNotes and organizationId, while the public invoice shape exposes neither. Because this shape is intentionally not the row, it correctly doesn’t derive from the row. That’s what a DTO is: the shape of data as it crosses a boundary, distinct on purpose from how you store it. You’ll build these properly much later in the course. For now, just know the carve-out exists so you don’t try to force a public response to be $inferSelect.
Carve-out 2: derived view shapes. Sometimes you need a shape that’s a projection of the row, like a dashboard summary or a list-row that’s lighter than the full invoice. That’s a real, distinct shape, and you do write it by hand. The key is how you write it: you compose it out of inferred pieces rather than retyping field names.
type InvoiceSummary = Pick<Invoice, 'id' | 'status' | 'amountDue'> & { organizationName: Organization['name'];};Look at what that InvoiceSummary does and doesn’t do. It names which fields it wants, id, status, and amountDue, but it does not declare their types. Pick reads those off the inferred Invoice, and Organization['name'] reads the type of the organization’s name straight off the inferred Organization. So rename amountDue in the schema and this summary breaks at compile time too, because it points at the schema rather than paraphrasing it.
This gives you a test for telling a legitimate carve-out apart from drift waiting to happen. Ask whether the shape restates a field name and its type that the schema already knows. If it spells out total: string by hand, that’s a fork, and the fork will drift the next time the schema changes. If instead it projects or narrows existing inferred members, using Pick, Omit, &, or Type['field'] indexed access, it’s a legitimate derived shape anchored to the source. The carve-out isn’t permission to write a type. It’s permission to compose a new shape out of the inferred ones.
The principle is more than a belief to hold. It changes the actual sequence of moves you make when a column needs to change. There’s a right order, and following it turns the type checker into your assistant.
Change the column in db/schema.ts first. Change the source before anything else, before any type, any query, or any 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 walks the whole codebase and surfaces every consumer of the changed shape as a compile error: queries, props, the derived summaries, all of it.
Fix each error the compiler points you at, then ship. Most are mechanical renames the squiggle lands you right on top of.
Consider what that sequence buys you. Without it, a schema change is an exercise in human memory: now let me try to recall every place that touched this column. That list is always lossy, since you’ll miss the one in lib/types.ts, the one in the export job, and the one a teammate added last week. With it, a schema change becomes “follow the red squiggles”: the compiler walks the codebase for you and hands you an exhaustive list of every consumer, because they’re all branches of the file you just edited. You’ve replaced your own memory with a tool that doesn’t forget.
The schema-first order is what unlocks that. Edit the schema first and the type checker can point at every stale consumer downstream. Do it backwards, patching a hand-typed interface or a query first and leaving the schema for last, and the compiler can’t help you, because the source of truth moved last. You’re back to hunting consumers by hand, which is exactly the lossy memory game the principle exists to remove.
Almost all of Principle #2’s value is downstream, in what it buys you in chapters you haven’t reached yet. Here is that reach: the same db/schema.ts you’ll write next is the root that all of this 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.)
That’s why a hand-typed row interface isn’t a small style nit. It quietly cuts the root off from one of those branches, and every guarantee that branch was supposed to provide goes with it.
That leaves three moves that break this principle in practice. It’s worth learning to spot each by name, since these are the things to flag the moment you see them in review.
Hand-typed row interfaces that go stale unnoticed. This is the main one. A type Invoice = { … } written out by hand, anywhere in the codebase, is the signal that Principle #2 got skipped, and it’s the exact thing that drifted in our four-way failure. (Later in this chapter, $inferSelect becomes the one-line replacement that makes the whole category disappear.)
as any to bridge a stale type onto a new schema. When a hand-typed shape finally throws an error after a schema change, the tempting fix is to drop as any on it and move on. But that error was the one signal telling you drift had happened. as any doesn’t fix the drift. It hides the warning and ships the bug, now harder to find because the compiler has been told to stop looking.
Copying a Zod schema’s field list off the Drizzle table by hand. This one feels responsible, since you’re writing a validator, but if you transcribe the fields instead of generating them with createInsertSchema / createSelectSchema, you’ve recreated the exact same drift one layer over. The two field lists agree today and silently diverge the next time someone edits the schema. It’s the four-way drift again, in slow motion.
Now make sure you can tell a broken move from a legitimate carve-out, because that judgment is the skill this lesson is teaching.
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'>That exercise drills one question. The test isn’t whether there’s a type keyword in play; it’s whether the shape restates what the schema knows or composes from it. $inferSelect, createInsertSchema, Pick<Invoice, …>, and Partial<NewInvoice> are all anchored to the source. A hand-typed type Invoice and a hand-copied Zod field list are forks. Same type keyword, opposite verdicts.
One last check, on the failure mode itself, since the silence is the part beginners tend to 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.Here’s the whole lesson in short. The same row shape shows up on five surfaces. Exactly one of them, db/schema.ts, is canonical, and the other four must be generated from it, because anything hand-copied drifts silently and surfaces as a production 500. You hand-write only two kinds of shape, a deliberately different API DTO and a view projection composed from inferred pieces, and both stay anchored to the schema. When a column changes, you change the schema first and let the type checker walk the codebase for you. That’s Principle #2. Next, you build the file that the whole tree hangs off.