$inferSelect and $inferInsert
Derive your app's TypeScript row types straight from the Drizzle schema with $inferSelect and $inferInsert, so read and write shapes can never drift from the database.
Over the last several lessons you built the whole thing: the organizations table, the invoices table, the line items, the tags, the junctions, the relations. Every column, every type, every constraint now lives in one file. When this chapter opened with “the schema is the source of truth,” it made a promise: that this db/schema.ts would be the root of a derivation tree, where every typed shape your app needs downstream is generated from it rather than retyped by hand. A hand-written type Invoice scattered across five files is a copy, and a copy goes stale the instant someone renames a column.
This lesson is where that promise comes due. Say you have a Server Component that needs an Invoice[] prop. Where does that Invoice type come from? Not from a hand-typed interface, but from the schema, in one line. By the end you’ll be able to delete any type Invoice you’d ever write and replace it with a single expression that cannot drift, because it reads the table you already built. Two helpers do this, $inferSelect and $inferInsert. Together they are what makes the “change one file, every consumer’s type checker lights up” loop actually work.
$inferSelect: the row you read back
Section titled “$inferSelect: the row you read back”Here’s the first helper, and the one you’ll reach for most. Every table you’ve built, invoices, organizations, all of them, carries a property called $inferSelect. It resolves to the TypeScript type of one row, exactly as you’d get it back from a db.select() . You don’t compute it and you don’t maintain it: Drizzle derives it from the column definitions you already wrote.
One detail is easy to miss, so get it right from the start: $inferSelect lives at the type level, not the value level. You reach it through typeof:
type Invoice = typeof invoices.$inferSelect;You never write invoices.$inferSelect as a runtime value: there’s nothing to run, since it’s purely a type-side projection of the table. Read typeof invoices.$inferSelect as “the type of the rows in the invoices table.”
That one line is the payoff of this chapter. Anywhere you’d have hand-typed this:
type Invoice = { id: string; organizationId: string; amountDue: string; status: 'draft' | 'sent' | 'paid' | 'void'; assignedToId: string | null; createdAt: Date; tags: string[];};You write this instead, and the whole category of “stale hand-typed row interface” disappears:
type Invoice = typeof invoices.$inferSelect;The two produce the same type today. The difference is that the second one re-derives itself the moment you touch the schema, while the first one quietly goes stale the moment someone else does.
So what does that inferred type actually contain? Every column maps to a TypeScript member by a fixed set of rules, driven by the same per-column builders you chose when you picked your data types. Here is the invoices table walked one column at a time, so you can see how each one lands.
export const invoices = pgTable('invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid().notNull(), amountDue: numeric({ precision: 12, scale: 2 }).notNull(), status: invoiceStatus().notNull().default('draft'), assignedToId: uuid(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), tags: text().array().notNull(),});
type Invoice = typeof invoices.$inferSelect;// {// id: string;// organizationId: string;// amountDue: string;// status: 'draft' | 'sent' | 'paid' | 'void';// assignedToId: string | null;// createdAt: Date;// tags: string[];// }Start with the plain cases. uuid() becomes string, and a .notNull() column lands as the bare type, with no | null. Both id and organizationId are just string on the read side.
export const invoices = pgTable('invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid().notNull(), amountDue: numeric({ precision: 12, scale: 2 }).notNull(), status: invoiceStatus().notNull().default('draft'), assignedToId: uuid(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), tags: text().array().notNull(),});
type Invoice = typeof invoices.$inferSelect;// {// id: string;// organizationId: string;// amountDue: string;// status: 'draft' | 'sent' | 'paid' | 'void';// assignedToId: string | null;// createdAt: Date;// tags: string[];// }This next one surprises people. numeric({ precision: 12, scale: 2 }) infers as string, not number. That’s deliberate: money carries more precision than a JS number can hold without rounding, so it arrives as a string and a decimal library handles the math. Expect amountDue: string and read it as the precision guarantee.
export const invoices = pgTable('invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid().notNull(), amountDue: numeric({ precision: 12, scale: 2 }).notNull(), status: invoiceStatus().notNull().default('draft'), assignedToId: uuid(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), tags: text().array().notNull(),});
type Invoice = typeof invoices.$inferSelect;// {// id: string;// organizationId: string;// amountDue: string;// status: 'draft' | 'sent' | 'paid' | 'void';// assignedToId: string | null;// createdAt: Date;// tags: string[];// }A pgEnum column lands as the exact string-literal union of its members. The type carries the closed set, so status can only ever be one of the four.
export const invoices = pgTable('invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid().notNull(), amountDue: numeric({ precision: 12, scale: 2 }).notNull(), status: invoiceStatus().notNull().default('draft'), assignedToId: uuid(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), tags: text().array().notNull(),});
type Invoice = typeof invoices.$inferSelect;// {// id: string;// organizationId: string;// amountDue: string;// status: 'draft' | 'sent' | 'paid' | 'void';// assignedToId: string | null;// createdAt: Date;// tags: string[];// }assignedToId is uuid() with no .notNull(), so it’s nullable in the database, and that nullability is collected right here as string | null. This is where the cost of a nullable column comes due: every reader now has to handle the null.
export const invoices = pgTable('invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid().notNull(), amountDue: numeric({ precision: 12, scale: 2 }).notNull(), status: invoiceStatus().notNull().default('draft'), assignedToId: uuid(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), tags: text().array().notNull(),});
type Invoice = typeof invoices.$inferSelect;// {// id: string;// organizationId: string;// amountDue: string;// status: 'draft' | 'sent' | 'paid' | 'void';// assignedToId: string | null;// createdAt: Date;// tags: string[];// }.array() wraps the element type, so text().array() becomes string[] for the tags column.
Walk that table top to bottom and the mapping is mechanical. text → string, integer → number, boolean → boolean, uuid → string, timestamp({ withTimezone: true }) → Date, a pgEnum to its literal union, .array() to T[]. A .notNull() column gives you the bare type; a nullable column gives you T | null. The two columns worth pausing on are numeric → string, which is a precision choice rather than a defect, and jsonb, which we’ll come back to at the end of the lesson.
That amountDue: string deserves one more beat, because it’s the line people most often push back on.
type Amount = Invoice['amountDue']; // stringNow read one for yourself. The table below is fixed; your job is only to read the type the compiler infers and prove you understand the two cases that catch people out. The ^? query surfaces the resolved type, and the @ts-expect-error line proves amountDue really is a string. Make the editor go quiet.
The `invoices` table is given, so you don't need to touch it. Read the inferred `Invoice` type where the `^?` points: it should resolve to the full row, with `amountDue` as a `string`. The `@ts-expect-error` on the last line proves that string is not a number; leave it in place, and the checker stays quiet only while the assignment below it genuinely fails.
-
Type query at line 13 must resolve to a type containing
amountDue: string
$inferInsert: only what you must supply
Section titled “$inferInsert: only what you must supply”$inferSelect was the easy half: one row, one-to-one with what’s stored. Its sibling carries a more interesting idea.
$inferInsert is the type that db.insert(invoices).values(...) accepts, the shape of a row you’re creating. You reach it the same way, at the type level, through typeof:
type NewInvoice = typeof invoices.$inferInsert;And here’s the part that surprises people: NewInvoice is not the same as Invoice. It’s narrower. The row you read back has id, createdAt, and status fully populated, but when you insert, you don’t supply those, because the database fills them in. So why would the insert type force you to provide a value the database is going to generate anyway?
It doesn’t. The asymmetry between read and write is just the schema’s own facts projected into TypeScript. A column with a default, a column the database computes, and a column the app is genuinely responsible for are three different obligations, and $inferInsert encodes all three. Three rules govern how a column on $inferSelect becomes, or doesn’t become, a member on $inferInsert. They mirror the column-modifier checklist you already know, so they should feel familiar.
Rule 1, a column with a default becomes optional. Any column with .default(...), .defaultNow(), or .$defaultFn(...) becomes optional on insert. You may pass a value; if you don’t, the database or Drizzle fills it. The clearest case is the primary key: id: uuid().primaryKey().default(sql\uuidv7()`)has a default, so it's optional on$inferInsert. You almost never pass an idyourself; you let the database mint a fresh UUIDv7. The same holds forcreatedAt(adefaultNow()SQL default) andstatus(a.default(‘draft’)). There are two flavours of default: a SQL DEFAULTclause the database applies, and a$defaultFn` that Drizzle runs in JS before sending the insert. The mechanism differs, but the result is the same, and the column is optional either way.
Rule 2, a generated column is omitted entirely. A column declared .generatedAlwaysAs(...) or generatedAlwaysAsIdentity() is absent from $inferInsert. Not optional, but gone. The database computes it from other columns, so passing one of your own is a type error. Take the emailLowercased column on users: it’s a .generatedAlwaysAs(...) column computed from email, so it never appears on the insert type. Keep this distinct from Rule 1: optional is not omitted. A defaulted column is there and you may skip it; a generated column isn’t there at all.
Rule 3, a .notNull() column with no default is required. That’s everything left over. A column that’s NOT NULL and has no default is one the app must provide, because there’s no value for the database to fall back on. On invoices, that’s organizationId and amountDue: every insert has to carry both, and the type enforces it. Everything else falls out of Rules 1 and 2.
There’s one nuance here that catches people, and it’s worth slowing down for. A defaulted column is made optional, which means undefined is fine and you may omit it. It does not become nullable. Passing null is only legal if the column is itself nullable. So createdAt?: Date and assignedToId: string | null look similar but mean opposite things: ?: T says “you don’t have to provide this,” while : T | null says “you may provide nothing on purpose.”
Now put the two shapes side by side so the difference is plain. The example switches to the users table for a moment, because it exercises all three rules at once: it has defaulted columns (id, createdAt), a required one (email), and the generated emailLowercased from earlier in the chapter.
type User = typeof users.$inferSelect;// {// id: string;// email: string;// emailLowercased: string;// createdAt: Date;// }Everything stored, fully known. id and createdAt are required members: read a row back and they’re populated. emailLowercased (a generated column) is here too, because the database computed it and you read it.
type NewUser = typeof users.$inferInsert;// {// id?: string;// email: string;// createdAt?: Date;// }Only what the app must supply. id and createdAt carry defaults, so they’re optional (?:), by Rule 1. emailLowercased is generated, so it’s gone entirely, with no line to highlight because it’s simply absent, by Rule 2. email is NOT NULL with no default, so it’s required, by Rule 3.
Read the two tabs against each other and the three rules are right there. Three columns changed between read and write: id and createdAt went from required to optional because they have defaults, and emailLowercased vanished because it’s generated. The one that didn’t change, email, is exactly the column the app is on the hook for.
Now build one. The starter gives you the table and a NewInvoice type; you complete a draft object so tsc goes quiet. Two things are wrong with the object as written: a required field is missing, and a field you’re not allowed to supply is present. Fix both by reading the insert type.
Complete the `draft` object so it satisfies `NewInvoice`. The object is missing a required field and is wrongly supplying one the database owns. Read the error, add the required field, and remove the `id` line: `id` carries a default, so you let the database mint it rather than passing one by hand.
- Fix all errors
Why read and write are two shapes
Section titled “Why read and write are two shapes”Step back from the mechanics for a second, because the reason is worth holding onto: once you have it, you can predict the insert shape of a column you’ve never seen.
Here it is in one line: read returns everything stored; write accepts only the subset the app is responsible for. The database owns the rest, the values it defaults and the columns it computes. $inferSelect is the full row because reading gives you the full row. $inferInsert is narrower because inserting is only your half of the work; the database does the other half.
This is precisely the thing a hand-written interface can’t express. To say “this field is required when you read it but optional when you write it,” you’d need two separate types, both maintained by hand, both drifting independently. The schema says it once. One pgTable declaration knows both shapes, because the facts that distinguish them are facts the schema already records: which columns have defaults, which are generated, and which are NOT NULL. That’s the deeper reason behind “generate, don’t hand-copy”: it’s not just less typing, it’s expressing a relationship that plain TypeScript has no way to state.
The diagram makes it concrete. Think of the row as a set of column chips. Reading hands you all of them; writing hands you a strict subset, with defaulted chips optional and generated chips gone.
typeof users.$inferSelect id email emailLowercased createdAt typeof users.$inferInsert id optional email emailLowercased omitted createdAt optional This same split shows up again later, one layer down. When you generate runtime validators from the schema (a library called drizzle-zod, a couple of units from now), it ships two generators, createSelectSchema and createInsertSchema, for exactly this reason. Read and write are two shapes everywhere the schema touches: at the type level here, at the validation level there. Same asymmetry, same single source.
Place the types next to the table, name them well
Section titled “Place the types next to the table, name them well”You’ve seen the line. So where does it live, and what do you call it? There’s a convention here, and the project chapters lean on it, so it’s worth pinning down.
Put the type exports directly under the table they derive from, right there in db/schema.ts:
export const invoices = pgTable('invoices', { id: uuid().primaryKey().default(sql`uuidv7()`), organizationId: uuid().notNull(), amountDue: numeric({ precision: 12, scale: 2 }).notNull(), status: invoiceStatus().notNull().default('draft'), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),});
export type Invoice = typeof invoices.$inferSelect;export type NewInvoice = typeof invoices.$inferInsert;The type and its source sit in one file, three lines apart, so nobody ever hunts for “where’s the Invoice type.” And note what these two export type lines are: re-exports of a derived shape, not hand-copies. The first lesson of this chapter separated retyping the schema, which is forbidden because it drifts, from deriving from the schema, which is encouraged. These lines derive: they restate zero field names. This is the one place where writing a type for a row is not just allowed but correct.
Here is the naming convention. It’s worth memorizing, because it’s the course standard and the projects reuse it verbatim:
- The select type is the entity noun:
Invoice,Organization,Membership. - The insert type is the noun with a
Newprefix:NewInvoice,NewOrganization,NewMembership.
Downstream files pull these in as type-only imports, import type { Invoice } from '@/db/schema', because they’re type-only symbols and your TypeScript config enforces the import type form for them. One line, nothing more to it.
One historical footnote, so you recognize it in the wild.
Compose new shapes without restating fields
Section titled “Compose new shapes without restating fields”So far you’ve replaced hand-typed rows one-for-one. This next move is where most of the real-world payoff lives. Most of the time you don’t want the whole row. A list view wants three columns plus the org’s name; an update form wants a partial. The temptation is to hand-type those narrower shapes. Instead, build them from the inferred types, and they inherit the same can’t-drift guarantee.
The rule is that every derived shape roots in $inferSelect or $inferInsert. You never restate a field name; you compose with the utility types you already know. Here’s the canonical example, a summary type for a dashboard list:
type InvoiceSummary = Pick<Invoice, 'id' | 'status' | 'amountDue'> & { organizationName: Organization['name'];};
// The 80% update shape — a partial of the insert type:type InvoiceUpdate = Partial<NewInvoice>;
// The precise update shape — id required (which row?), the rest optional:type PreciseUpdate = { id: Invoice['id'] } & Partial<Omit<NewInvoice, 'id'>>;Pick pulls those three members off the inferred Invoice, both the names and their types, straight from the source. Rename amountDue in the schema and this line breaks at compile time too. Zero field types restated.
type InvoiceSummary = Pick<Invoice, 'id' | 'status' | 'amountDue'> & { organizationName: Organization['name'];};
// The 80% update shape — a partial of the insert type:type InvoiceUpdate = Partial<NewInvoice>;
// The precise update shape — id required (which row?), the rest optional:type PreciseUpdate = { id: Invoice['id'] } & Partial<Omit<NewInvoice, 'id'>>;Indexed access, Type['field'], reads one field’s type directly out of the source type. organizationName gets whatever Organization.name is, without you naming string. The join column borrows its type from the column it joins to.
type InvoiceSummary = Pick<Invoice, 'id' | 'status' | 'amountDue'> & { organizationName: Organization['name'];};
// The 80% update shape — a partial of the insert type:type InvoiceUpdate = Partial<NewInvoice>;
// The precise update shape — id required (which row?), the rest optional:type PreciseUpdate = { id: Invoice['id'] } & Partial<Omit<NewInvoice, 'id'>>;Partial<NewInvoice> makes every insert field optional. This is the 80% answer for an update payload, where the caller sends only the columns that changed.
type InvoiceSummary = Pick<Invoice, 'id' | 'status' | 'amountDue'> & { organizationName: Organization['name'];};
// The 80% update shape — a partial of the insert type:type InvoiceUpdate = Partial<NewInvoice>;
// The precise update shape — id required (which row?), the rest optional:type PreciseUpdate = { id: Invoice['id'] } & Partial<Omit<NewInvoice, 'id'>>;The precise shape, for when you want the type to enforce that you must say which row. id is pinned required via indexed access; Omit drops id from the insert type, then Partial makes the remaining columns optional. It’s more precise but takes a little more machinery, so reach for it when the looseness of Partial<NewInvoice> actually causes a problem.
Look at what’s not in that block: not a single string, not a single 'draft' | 'sent' | …, not one field type written by hand. Pick, Omit, Partial, the & intersection, and the Type['field'] indexed access are the five tools of composition, and you met all of them in the TypeScript lessons. Here they’re doing real work: every shape that InvoiceSummary, InvoiceUpdate, and PreciseUpdate describes is anchored to the schema. Rename a column and they all break at the type checker, which is exactly what you want, because a break at compile time is a bug you never shipped.
On the update shapes, Partial<NewInvoice> is the answer you’ll reach for most. The id-pinned variant is more correct, since it won’t let you build an update that forgot to say which row, but don’t reach for it reflexively. Name the trade-off, and pick the simple one until precision earns its keep.
Your turn. Write InvoiceSummary from scratch so it resolves to the expected members, and prove the anchor holds: the @ts-expect-error line reaches for a field your summary deliberately leaves out (organizationId), and that access should fail.
Write `InvoiceSummary` so it has `id`, `status`, `amountDue` (picked from `Invoice`) plus an `organizationName` (the type of `Organization.name`). Use `Pick` and indexed access, and restate no field types. The `@ts-expect-error` line confirms your summary really is anchored: reaching for a field the summary doesn't include (`organizationId`) should fail.
-
Type query at line 25 must resolve to a type containing
organizationName: string
One type, one source: the prop pattern
Section titled “One type, one source: the prop pattern”Here is the whole thing flowing, end to end, because “one type, one source” stays abstract until you watch a single name thread every layer.
A Server Component reads invoices from the database and hands them to a Client Component to render. (The query itself is the next chapter’s job; here, just picture its result.) Follow the Invoice type through it:
export type Invoice = typeof invoices.$inferSelect;
// db/queries/invoices.ts — the next chapter owns the query bodyexport const listInvoices = async (): Promise<Invoice[]> => { // ...};
// app/invoices/invoice-list.tsxtype Props = { invoices: Invoice[] };
export const InvoiceList = ({ invoices }: Props) => { // render the rows};The same Invoice appears four times: the schema export, the query’s return annotation, the component’s Props, and the destructured parameter. It’s one declaration the whole way down. The schema defines it; everything else borrows it. Now run the counterfactual from the first lesson of this chapter. If InvoiceList hand-typed its own prop interface, a column rename in the schema would update the query but leave that prop quietly stale. The Props would no longer match the data flowing into it, and the type checker would say nothing, because the two were never connected. Thread one type through instead, and the rename breaks every layer at once, which is the only way you find out before production does.
And the chain keeps going past the type. The same invoices table feeds the query’s row type, the component prop, and, once you reach forms and Server Actions, the runtime validator that parses the action’s input (drizzle-zod again). Schema table → $inferInsert for the type → drizzle-zod for the validator → both consumed by the Server Action that writes the row. Every layer aligns because every layer reads the same root.
When inference is wrong, and the relations gap
Section titled “When inference is wrong, and the relations gap”Inference is excellent, but it has limits, and an experienced engineer knows where they are. There are four edges here. None of them are defects; they are all things to expect, so that they don’t surprise you later.
jsonb without $type infers as unknown. A bare jsonb() column tells the compiler nothing about its contents, so $inferSelect gives you unknown: correct, but useless to work with. The fix isn’t a cast at the read site; it’s upstream, on the column, exactly where you learned it. Declare the shape with $type<...>().
export const webhookDeliveries = pgTable('webhook_deliveries', { payload: jsonb().notNull(),});
type Delivery = typeof webhookDeliveries.$inferSelect;// payload: unknown ← nothing to work withInference is only as good as what you told the schema. A bare jsonb() carries no shape, so the row’s payload is unknown, and you’d have to narrow it by hand at every read.
export const webhookDeliveries = pgTable('webhook_deliveries', { payload: jsonb().$type<WebhookEvent>().notNull(),});
type Delivery = typeof webhookDeliveries.$inferSelect;// payload: WebhookEvent ← the real shape$type<T>() is a compile-time promise about the stored shape. $inferSelect now resolves payload to WebhookEvent. Postgres still stores bytes and Zod still validates the data on the way in; $type only informs the type, and it doesn’t enforce anything at runtime.
So $type tells the compiler what to expect, but it doesn’t check anything at runtime: confirming that the stored bytes actually match WebhookEvent is Zod’s job at the boundary. The same idea applies to the next two edges: when inference gives you something too wide, the fix is in the schema, not a cast at the consumer.
numeric stays string. You met this already: amountDue: string, not number. It’s listed here too because it belongs to the same family of “the type isn’t what you’d first expect,” but it’s deliberate. The string preserves arbitrary precision, and the chapter doesn’t “fix” it because there’s nothing broken. Read numeric → string as the money guarantee, every time.
An enum-like text column gives you string, not a union. If a column holds a fixed set of values but you declared it as plain text(), $inferSelect infers string and you’ve lost the closed set: the compiler will let 'pending' sit in a column that only ever holds 'draft' | 'sent' | 'paid' | 'void'. The fix is upstream again: make it a pgEnum rather than casting at the read site, and the union comes back for free, with every consumer inheriting the narrow type.
$inferSelect is flat: it does not include relations. This is the edge people get wrong most often. An inferred Invoice has organizationId: string, the raw foreign-key column, and that’s all. There is no nested organization object, no lineItems array, no tags. The relations you declared in the previous lesson have their own inferred result types, produced by the relational query API when you actually ask for them (db.query.invoices.findMany({ with: { tags: true } }), next chapter’s territory). $inferSelect is the flat stored row; nested shapes are a query-time concern, by design.
One last note in the same spirit: if a query selects only some columns rather than the whole row, its result type is narrower than $inferSelect, and you should let it infer rather than restate that shape by hand. Custom-select result types are the next chapter’s topic; just know that the same principle, don’t hand-type what the query already infers, extends there too.
Here’s a quick check on the relations gap.
You write type Invoice = typeof invoices.$inferSelect, and invoices has a relation to organizations declared with defineRelations. What does Invoice give you for the linked organization?
organizationId: string, the raw foreign-key column. The organization itself is a separate type you reach for at the query layer.organization: Organization, always populated, since declaring the relation wires it into the row.organization: Organization | null — present when the row points at one, null otherwise.organization the first time you read the property.$inferSelect is the flat stored row — it sees columns, including the organizationId FK, but never relations. Declaring a relation with defineRelations adds a query-time shape (db.query.…({ with })) with its own inferred type; it doesn’t change the flat row. The other three describe an ORM that hydrates relations into the row automatically — Drizzle deliberately keeps the flat row and the nested query shape separate.The codebase rewrites itself
Section titled “The codebase rewrites itself”Here’s the loop you can now picture from end to end. You change a column in db/schema.ts, whether you rename it, retype it, or make it NOT NULL. The inferred types update on their own, because they are the schema. You run the type checker, and every consumer that touched the old shape lights up red at once: the query’s row type, the component prop, the composed InvoiceSummary, the update payload. You walk the errors, fix each one, and ship, having found every break at edit time instead of in production.
That’s the promise from the first lesson of this chapter, now made true. Back then it was a claim: change one file, and every downstream layer’s type checker catches the drift. $inferSelect and $inferInsert are what make the claim hold, by connecting the schema to everything that reads it. The first lesson named the principle, the lessons in between built the file, and this one made every downstream shape a branch of it. So the hand-typed type Invoice isn’t just discouraged anymore; it’s unnecessary, because the one-line derivation is better in every way that matters: shorter to write, impossible to drift, and free to maintain.
That’s the chapter. You have a schema that’s genuinely the source of truth, and the two helpers that make “source of truth” mean something. The quiz next will let you check what stuck.
External resources
Section titled “External resources”A few references worth keeping. The first is the page you’ll come back to for the exact behavior of these helpers; the second points forward to the runtime half of the read/write split you just learned.
The canonical reference for $inferSelect / $inferInsert and the InferSelectModel / InferInsertModel aliases.
The schema-to-Zod pipeline. createSelectSchema / createInsertSchema mirror this lesson's read/write split at the runtime validation layer.
The official reference for Pick, Omit, and Partial, the composition tools that turn an inferred row into narrower derived shapes.