Schemas
Data schemas and validation
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 a single definition keeps everything in lockstep: the database shape, runtime validation at your trust boundaries, and the inferred TypeScript types. Add a field to the table and your validation and types follow automatically.
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 peers on Drizzle's Zod bridge. Import Drizzle table builders
from Drizzle, and import Zod itself directly from zod when you compose
product-specific schemas.
Start from a table
Zod schemas are derived from a Drizzle table, so everything begins with one. The
table is the database source of truth and lives in your database package under
{project}-db/src/schemas/. Defining tables and the Zeno helpers it uses
(table, primaryId, authUserId, auditColumns, RLS policies, …) are
covered in Tables; the
example below is just the input the rest of this page builds on.
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(),
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 columns are present. Nullable columns accept null. |
postSchema.insert | Data accepted before an insert. Generated/defaulted columns are omitted or optional according to Drizzle metadata. |
postSchema.update | Data accepted before an update. Updatable fields are optional; generated columns are omitted. |
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: the moment you refine, omit, extend, or coerce a variant (as in the
sections below), the $infer types can no longer describe it, but z.infer of
the actual schema always can.
Refine each variant
Pass refinement maps when the database shape needs richer validation. The maps match 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 rules intentionally when the same constraint belongs in more
than one workflow.
For advanced cases such as coercion, pass a Drizzle schema factory option:
export const postSchema = defineTableSchema(posts, {
factory: {
coerce: {
date: true,
},
},
})Derive form and action schemas
Zeno does not generate form-specific schemas automatically. 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 explicitly with Zod, in the same schemas.ts alongside the
table-derived postSchema:
import { z } from "zod"
export const createPostFormSchema = postSchema.insert
.omit({
createdAt: true,
id: true,
slug: true,
updatedAt: 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"
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,
})
return (
<FormProvider form={form}>
<Form>
<form.InputField label="Title" name="title" />
<form.TextAreaField label="Body" name="body" />
<form.CheckboxField label="Publish now" name="publishNow" />
<form.SubmitButton>Save</form.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. 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.