Skip to content
Chapter 37Lesson 1

Principle #2: the schema is the source of truth

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.

Drizzle table what's stored on disk
Server Component prop what's passed in to render
Zod validator what the boundary checks before trusting input
Server Action args what the mutation accepts when you save
Form field set what inputs the UI renders
the invoice row shape
idtotalstatusdueDateorganizationId
The same handful of columns, restated in five different places, each spelling living on its own with nothing connecting it to the others.

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.

  • Option A: each of the five surfaces owns its own copy of the shape. You type 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 B: one surface is the source, and the other four are generated from it. You declare the shape once, and the rest read it.

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.

STEP 1 / 5 Day one Every spelling names the same column. The feature works.
source of truth Drizzle table the source — what's stored total aligned
Invoice type the Server Component prop total aligned
Zod validator what the form checks total aligned
Server Action args what the mutation accepts total aligned
Form field set the inputs the UI renders 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.

STEP 2 / 5 The rename lands The column is renamed in Postgres and in the schema. The source of truth has moved.
source of truth Drizzle table the source — what's stored amountDue aligned
Invoice type the Server Component prop total aligned
Zod validator what the form checks total aligned
Server Action args what the mutation accepts total aligned
Form field set the inputs the UI renders 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.

STEP 3 / 5 Four shapes drift The four hand-written copies still name total. TypeScript reports nothing.
source of truth Drizzle table the source — what's stored amountDue aligned
Invoice type the Server Component prop total TypeScript is happy
Zod validator what the form checks total TypeScript is happy
Server Action args what the mutation accepts total TypeScript is happy
Form field set the inputs the UI renders 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.

STEP 4 / 5 Production, first insert The deploy ships. The first save references a column that no longer exists.
source of truth Drizzle table the source — what's stored amountDue aligned
Invoice type the Server Component prop total runtime 500
Zod validator what the form checks total runtime 500
Server Action args what the mutation accepts total runtime 500
Form field set the inputs the UI renders 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.

STEP 5 / 5 The world where the four were generated Same moment — but now the four shapes are derived from the schema.
source of truth Drizzle table the source — what's stored amountDue aligned
Invoice type the Server Component prop total compile error
Zod validator what the form checks total compile error
Server Action args what the mutation accepts total compile error
Form field set the inputs the UI renders total compile error
4 silent failures → 4 compile errors caught the moment you save the file

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.

  • The row type, the shape of an invoice you read back, comes from invoices.$inferSelect .
  • The insert type, the shape you’re allowed to pass when creating one, comes from invoices.$inferInsert .
  • The Zod validator that guards your form and Server Action comes from Drizzle’s Zod generation, createInsertSchema / createSelectSchema, which reads the table and emits a matching schema. (You’ll build this when you reach forms and validation.)
  • The form’s field set comes from that same generated Zod: the field names are the schema’s keys, not a list you maintain by hand.
  • The RLS policy column names, much later, reference the same columns from the same file.

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.

db/schema.ts the invoices table
idtotalstatusdueDateorganizationId
Row type
the shape of an invoice you read back
Insert type
the shape you pass when creating one
Zod validator
guards the form and the Server Action
Form field set
the inputs the UI renders
RLS column names
which rows a user may see (much later)

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.

What you still hand-write: the two carve-outs

Section titled “What you still hand-write: the two carve-outs”

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.

lib/invoices.ts
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.

  1. Change the column in db/schema.ts first. Change the source before anything else, before any type, any query, or any form.

  2. 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.)

  3. 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.

  4. 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.

Where this principle pays off, and the moves that break it

Section titled “Where this principle pays off, and the moves that break it”

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.

Derive from the schema Generate it — a hand-copy here is the smell.
Legitimately hand-written A deliberate shape, still anchored to the schema.
A type Invoice you typed out by hand in lib/types.ts
type Invoice = typeof invoices.$inferSelect
A Zod schema whose fields you copied one by one off the Drizzle columns
A Zod schema produced by createInsertSchema(invoices)
A Partial<NewInvoice> for a patch payload, where NewInvoice is the inferred insert type
The public /api/invoices response, intentionally narrower than the stored row
type 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?

The build fails at the type Invoice line — once the schema changed, TypeScript flags the interface as out of date.
It builds and deploys clean. The mismatch stays invisible until the first save or read after deploy, then throws at runtime — because nothing wires that interface to the schema.
Nothing breaks: Drizzle rewrites the hand-typed interface to match, so the rename flows through to it.
The migration itself aborts, refusing to run while the schema and the hand-typed interface still disagree.

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.