Schemas
Derive Zod validation schemas from your Drizzle tables
A schema describes the shape and rules your data must satisfy: which fields exist, their types, which are required, and what counts as valid. You use it to validate untrusted input, such as a form submission or a request body, before your code trusts it.
Zod is the TypeScript-first schema library Zeno uses for that.
You describe your data once and get two things from the same definition: runtime
validation (schema.parse(input) throws on bad data) and a static TypeScript
type (z.infer<typeof schema>).
Your Drizzle table already describes that shape for the database. Redeclaring it by hand for validation means two definitions that drift apart the moment you add a column. Instead, Zeno derives the Zod schema from the table, so the database shape, runtime validation at your trust boundaries, and the inferred TypeScript types all come from one definition. When you add a column to the table, the derived schema validates it and the inferred type includes it, with no other edit.
Installation
Install the schema package wherever you define Drizzle tables:
pnpm add @zeno-lib/schema drizzle-orm@1.0.0-rc.3 zodMost projects use it with the database and forms packages:
pnpm add @zeno-lib/db @zeno-lib/schema @zeno-lib/forms drizzle-orm@1.0.0-rc.3 postgres zod
pnpm add -D drizzle-kit@1.0.0-rc.3 dotenv@zeno-lib/schema builds on Drizzle's Zod bridge and takes drizzle-orm and
zod as peer dependencies. Import Drizzle table builders from Drizzle, and
import Zod itself directly from zod when you compose product-specific schemas.
Start from a table
You derive Zod schemas from a Drizzle table, so start with the table. It is the
database source of truth and lives in your database package under
{project}-db/src/schemas/.
Tables covers how to
define one and the Zeno helpers it uses (table, primaryId, authUserId,
auditColumns, RLS policies, …). The rest of this page builds on the example
below.
import { auditColumns, authUserId, primaryId, table } from "@zeno-lib/db/schema"
import { text } from "drizzle-orm/pg-core"
export const posts = table("posts", {
id: primaryId("uuid"),
slug: text("slug").notNull(),
title: text("title").notNull(),
userId: authUserId({ notNull: true }),
body: text("body").notNull(),
...auditColumns(),
})The Zod schemas you derive from it live next to your forms in {project}-forms,
so they stay free of database clients and are safe to import from Client
Components.
Derive Zod schemas
Use defineTableSchema(table) to create the three database variants Zeno
standardizes on. Import the table from your database package:
import { defineTableSchema } from "@zeno-lib/schema"
import { posts } from "{project}-db"
export const postSchema = defineTableSchema(posts)postSchema contains:
| Schema | Use it for |
|---|---|
postSchema.select | Data read from the database. Generated and audit columns are present. Nullable columns accept null. |
postSchema.insert | Data accepted before an insert. Generated and audit columns are omitted; defaulted columns are optional according to Drizzle metadata. |
postSchema.update | Data accepted before an update. Updatable fields are optional; generated and audit columns are omitted. |
Audit columns stay out of writes
createdAt, updatedAt, createdBy and updatedBy are the keys
auditColumns(), timestamps() and authorship() emit. The database fills
them, through a column default and the triggers from @zeno-lib/db/triggers, so
insert and update leave them out. If a caller sends createdBy, the schema
strips it and nothing writes it. The match is by key and default, so a
createdAt column without a default stays writable. When a trusted path needs
to set one (a data import, say), build that schema with
createInsertSchema(...) instead.
Identity ids are positive
A column from sequentialPrimaryId() (or any identity column whose sequence
counts up from 1, which is Postgres's default) accepts only positive numbers, in
every variant. A function refinement on that column runs on top of the check; a
schema passed as the refinement replaces it.
These are plain Zod schemas. You can parse payloads in a Server Action or Route
Handler before querying through createAuthClient(...) (RLS-scoped) or
createAdminClient(...) (RLS bypass):
import { createAdminClient } from "@zeno-lib/db"
import * as schema from "{project}-db"
import { postSchema } from "{project}-forms/posts/schemas"
export async function createPost(input: unknown) {
const value = postSchema.insert.parse(input)
const db = createAdminClient()
await db.insert(schema.posts).values(value)
}Infer types from a schema
Each variant is a plain Zod schema, so its TypeScript type comes from z.infer:
export type InsertPost = z.infer<typeof postSchema.insert>
export type SelectPost = z.infer<typeof postSchema.select>Drizzle also exposes raw row types on the table object itself,
posts.$inferInsert and posts.$inferSelect (covered in
Tables). Those mirror the
table exactly. Prefer the Zod-inferred types once a schema diverges from the
table. When you refine, omit, extend, or coerce a variant (as in the sections
below), the $infer types no longer describe it, but z.infer of the actual
schema does.
Refine each variant
Pass refinement maps when a column needs rules its database type can't express, such as a minimum length or a pattern. The maps take the same form as the refinements in Drizzle's first-party Zod schema helpers.
export const postSchema = defineTableSchema(posts, {
insert: {
body: (schema) => schema.min(1),
slug: (schema) => schema.regex(/^[a-z0-9-]+$/),
title: (schema) => schema.min(3).max(120),
},
update: {
body: (schema) => schema.min(1),
slug: (schema) => schema.regex(/^[a-z0-9-]+$/),
title: (schema) => schema.min(3).max(120),
},
})Refinements are per variant. A rule under insert does not affect update or
select, so repeat a rule in each variant where the same constraint belongs.
For cases such as coercion, pass Drizzle schema factory options through
factory:
export const postSchema = defineTableSchema(posts, {
factory: {
coerce: {
date: true,
},
},
})Derive form and action schemas
Zeno does not generate form-specific schemas. Forms are product workflows. They may omit server-owned fields, add UI-only values, or require cross-field validation that the database table should not know about.
Derive those schemas yourself with Zod, in the same schemas.ts as the
table-derived postSchema:
import { z } from "zod"
export const createPostFormSchema = postSchema.insert
.omit({
id: true,
slug: true,
})
.extend({
publishNow: z.boolean().default(false),
})
.refine((value) => value.title.trim() !== value.body.trim(), {
message: "Title and body should not be identical.",
path: ["body"],
})
export type CreatePostForm = z.infer<typeof createPostFormSchema>Then pass the derived schema to @zeno-lib/forms:
"use client"
import { Form, FormProvider, useForm } from "@zeno-lib/forms/create-form"
import { createPostFormSchema } from "./schemas"
export function CreatePostForm() {
const form = useForm({
defaultValues: {
body: "",
publishNow: false,
title: "",
},
onSubmit: async ({ value }) => {
// Call a Server Action with validated form data.
console.log(value)
},
schema: createPostFormSchema,
})
const { CheckboxField, InputField, SubmitButton, TextAreaField } = form
return (
<FormProvider form={form}>
<Form>
<InputField label="Title" name="title" />
<TextAreaField label="Body" name="body" />
<CheckboxField label="Publish now" name="publishNow" />
<SubmitButton>Save</SubmitButton>
</Form>
</FormProvider>
)
}Where each file lives
Zeno splits a feature across two packages plus your app, so server-only database
code never leaks into modules a Client Component imports. {project} is your
workspace name and {table} is the entity (here, posts).
| Path | Should contain |
|---|---|
{project}-db/src/schemas/{table}.ts | The Drizzle table: the database source of truth and the input to migrations. |
{project}-db/src/schema.ts | Barrel re-exporting every table, imported by queries to reference tables and passed to defineRelations(...) for the relational query API. |
{project}-forms/src/{table}/schemas.ts | Table-derived Zod variants (defineTableSchema) and the UI/form schemas built from them. Pure, safe to import from Client Components. |
{project}-forms/src/{table}/forms.tsx | @zeno-lib/forms components for the table. |
Server Actions and Route Handlers that parse a schema and call @zeno-lib/db
live in your app at the server boundary. They import tables from {project}-db
and schemas from {project}-forms.
Direct Drizzle helpers
@zeno-lib/schema also re-exports Drizzle's first-party helpers for cases where
you do not want the { select, insert, update } bundle:
import {
createInsertSchema,
createSchemaFactory,
createSelectSchema,
createUpdateSchema,
} from "@zeno-lib/schema"Prefer defineTableSchema(...) for application tables so teams use the same
variant names across forms, actions, and tests.
Pitfalls
- Keep the module that exports your Zod schemas pure. It can import table
builders and
defineTableSchema, but it must not create database clients or read server-only request state, because Client Components import this module. - Do not import
createAuthClient(...)or other server-only code from a module a Client Component imports for validation. Keep database clients at the action/route boundary. - Refinements are per variant. A rule under
insertdoes not apply toupdateorselect, so repeat it in each variant where the constraint belongs. - Once you omit, extend, or coerce a schema, infer its type from the Zod schema
(
z.infer<typeof schema>), not the table's$inferInsert/$inferSelect; the$infertypes only know the raw table.