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

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
One invoice row at the center; the five surfaces that each restate its columns around it, with nothing tying the copies together.

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.

  • Option A: each surface owns its own copy. 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 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.

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. All five spellings name the same column, total, and the feature works.

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. The migration and the Drizzle schema turn total into amountDue, and the database and the schema agree.

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, silently. The four hand-copied surfaces still name total, and because none is linked to the schema, TypeScript reports nothing.

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, and the first saved invoice references a column that no longer exists, so production throws a 500.

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

  • The row type, the shape of an invoice you read back, comes from invoices.$inferSelect .
  • The insert type, the shape you pass when creating one, comes from invoices.$inferInsert .
  • The Zod validator that guards your form and Server Action comes from Drizzle’s createInsertSchema / createSelectSchema, which reads the table and emits a matching schema (you’ll build this with 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 reference the same columns from the same file.

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.

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

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

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

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.

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

  1. Change the column in db/schema.ts first. Edit the source before any type, query, or 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 surfaces every consumer of the changed shape as a compile error: queries, props, derived summaries, all of it.

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

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

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

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.

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'>

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?

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.

Next, you build the file the whole tree hangs off.