ZenoZENO

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 zod

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

{project}-db/src/schemas/posts.ts
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:

{project}-forms/src/posts/schemas.ts
import { defineTableSchema } from "@zeno-lib/schema"
import { posts } from "{project}-db"

export const postSchema = defineTableSchema(posts)

postSchema contains:

SchemaUse it for
postSchema.selectData read from the database. Generated and audit columns are present. Nullable columns accept null.
postSchema.insertData accepted before an insert. Generated and audit columns are omitted; defaulted columns are optional according to Drizzle metadata.
postSchema.updateData 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):

app/posts/actions.ts
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.

{project}-forms/src/posts/schemas.ts
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:

{project}-forms/src/posts/schemas.ts
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:

{project}-forms/src/posts/forms.tsx
"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).

PathShould contain
{project}-db/src/schemas/{table}.tsThe Drizzle table: the database source of truth and the input to migrations.
{project}-db/src/schema.tsBarrel re-exporting every table, imported by queries to reference tables and passed to defineRelations(...) for the relational query API.
{project}-forms/src/{table}/schemas.tsTable-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 insert does not apply to update or select, 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 $infer types only know the raw table.