Skip to content

Forms

@drzl/generator-forms emits form resolvers and per-field input metadata from your Drizzle schema, for react-hook-form and TanStack Form.

Two halves, and the second is the one that is hard to get anywhere else.

The resolver

The two libraries want different things, measured on 2026-08-12:

LibraryWhat it needs
react-hook-forma resolver. standardSchemaResolver serves zod, valibot and arktype with one import; TypeBox and Effect have dedicated ones in the same package
TanStack Formnothing. A Standard Schema goes straight into validators: { onChange: schema }

So all five of DRZL's validation generators can drive a react-hook-form form, and the three that expose ~standard can drive a TanStack one. Asking for the TanStack target with TypeBox or Effect is refused rather than emitted, because an options object naming a schema the form cannot read is silently ignored at runtime.

The @hookform/resolvers version matters

bash
npm install "@hookform/resolvers@>=5.0.0 <=5.4.0"

That cap is not caution about a number. From 5.4.1 onward @hookform/resolvers declares @typeschema/main as an optional peer, npm resolves optional peers, and that chain pins old validators:

@hookform/resolvers 5.4.3
  peerOptional @typeschema/main >=0.13.7
    peerOptional @typeschema/zod 0.14.0      -> peerOptional zod ^3.23.8
    peerOptional @typeschema/valibot 0.14.0  -> peerOptional valibot ^0.39.0

DRZL emits zod 4 and valibot 1, so a plain npm install into a project carrying either fails:

npm error Conflicting peer dependency: zod@3.25.76
npm error   peerOptional zod@"^3.23.8" from @typeschema/zod@0.14.0

5.4.0 and earlier declare no @typeschema peer and install cleanly. standardSchemaResolver has been there since 5.0.0, so the cap costs nothing this generator emits. pnpm and yarn resolve optional peers differently and may not hit it; the cap is what makes npm install work for everyone. The generator's suite pins the range, so the day a release drops that peer the bound can move and a test says so.

The field metadata, which is the point

ts
export const usersFields = {
  "handle": { control: "text", required: true, nullable: false, maxLength: 20 },
  "age":    { control: "number", required: true, nullable: false, min: "18", max: "130", integer: true },
  "tier":   { control: "select", required: true, nullable: false, options: ["free", "pro", "max"] },
  "ref":    { control: "text", required: true, nullable: false, pattern: "[0-9a-fA-F]{8}-..." },
  "active": { control: "checkbox", required: false, nullable: false, defaultValue: true },
} as const;

Every value there is a fact the database already enforces, so an <input> can carry it without a second source of truth:

tsx
<input
  type="number"
  {...register('age')}
  min={usersFields.age.min}
  max={usersFields.age.max}
  step={usersFields.age.integer ? 1 : undefined}
  required={usersFields.age.required}
/>

Why the bounds are not read off the column

A CHECK does not narrow Column.min and Column.max. Measured: a column declared integer with check('adult', age >= 18) still reports min: '-2147483648', the plain int32 range. The analyzer leaves checks on the table, and each validation generator folds them into its own emitted bounds at emit time.

A form generator reading the column directly would put min="-2147483648" on an input for a column the database restricts to 18. That is worse than emitting nothing: it looks like a bound and is not one, and the schema beside it would reject what the input accepted.

So the fold lives in @drzl/validation-core as fieldFacts, beside classifyTableChecks and tableConstraints, which are already the shared home for that question. The metadata above and the .gte(18) in the emitted schema come from the same place rather than from two derivations that agree until one of them changes.

The same holds for length. varchar(40) carrying CHECK (length(handle) <= 20) reports maxLength: 20, the tighter of the two, and an unbounded text column with only a length check gets a maxLength its type never declared. A byte-count check is not read: octet_length is not a character count, and maxlength on an input counts characters, so taking one would reject text the database accepts on every multi-byte character.

Setup

bash
npm install -D @drzl/generator-forms
npm install react-hook-form "@hookform/resolvers@>=5.0.0 <=5.4.0"   # or @tanstack/react-form
ts
// drzl.config.ts
export default {
  schema: './src/db/schema.ts',
  outDir: './src/api',
  generators: [
    { kind: 'zod', path: './src/validators/zod' },
    { kind: 'forms', path: './src/forms', target: 'react-hook-form' },
  ],
} as const;

A resolver with no schema is nothing, so a validation generator has to be in the config. Its importPath is derived from that entry's own path. The generator refuses otherwise.

Using it

tsx
import { useForm } from 'react-hook-form';
import { usersFields, usersInsertResolver } from './forms/users.form.js';

function NewUser() {
  const { register, handleSubmit, formState } = useForm({ resolver: usersInsertResolver });
  return (
    <form onSubmit={handleSubmit(save)}>
      <input {...register('handle')} maxLength={usersFields.handle.maxLength} />
      {formState.errors.handle && <span>{formState.errors.handle.message}</span>}
    </form>
  );
}

TanStack Form takes the schema directly:

ts
import { useForm } from '@tanstack/react-form';
import { usersInsertFormOptions } from './forms/users.form.js';

const form = useForm({ defaultValues: { handle: '' }, ...usersInsertFormOptions });

Options

OptionDefaultWhat it does
pathoutDirWhere the modules are written
targetreact-hook-formreact-hook-form, tanstack-form or both
modes['insert', 'update']Which operations get a resolver
validation.libraryzodWhich validation generator's schemas to import
validation.importPathderivedWhere those schemas are, if the sibling entry's path is not it
formatinheritedFormatter settings
outputHeaderinheritedThe generated-file banner
importExtensionjsHow the emitted relative imports spell their extension

select is off by default. A select schema describes a row that came out of the database, so validating a user's input against it asks for the generated columns a form never supplies. It is offered because a filter form is a form too.

A read-only relation gets a select module only, since it has no insert or update schema to resolve against.

See also: Zod · openapi-fetch · Configuration

Need a custom template or integration? DM @omardulaimidev on X.