Zod forms
Defines typed forms with Zod 4 where one z.object schema is the single source of both the TypeScript type and runtime validation, coercing string inputs with z.coerce.number() and resolving error messages through next-intl keys. Use when writing or reviewing a Zod schema, wiring react-hook-form or a Server Action validator, fixing a Select "all" sentinel that fails validation, coercing numeric or date inputs, translating validation messages, or migrating a schema from Zod 3 to Zod 4. Not for DRF-side field validation or the shared client/server contract (see drf-zod-contract), locale message wiring itself (see i18n-rtl), or mutation/query plumbing (see react-query).From its SKILL.md
npx -y skills add Deadlymind/nanolama --skill zod-formsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 24 days oldThe repository was created 24 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
4.8 KB, ~1.1k tokens by cl100k_base, as published. Nobody here has run it
Zod forms (schema as single source of truth)
When to use
Building or reviewing any Next.js form or Server Action input on this stack. Reach
here when you need one schema to produce both the type and the runtime check, when
<input> string values must become numbers, when a Select's "all" option breaks
validation, or when error messages must be French/English/Arabic, not hardcoded.
Pattern
One z.object is the source of truth: derive the TypeScript type with
z.infer, validate with .safeParse, and never maintain a parallel interface.
HTML inputs are always strings — coerce at the schema edge (z.coerce.number()),
don't sprinkle Number(...) through handlers. Emit message keys, not English
prose, and resolve them via next-intl so the same schema serves all three locales.
Steps / idioms
-
Import from
zod(this resolves Zod 4) and let one schema define the type, coerce inputs, and model the Select sentinel; validate anywhere withsafeParseand map each issue key through next-intl:import { z } from 'zod'; // "all" is the Select's no-filter sentinel; treat it as "field omitted". const ALL = 'all'; export const invoiceFilterSchema = z.object({ // <input> gives a string; coerce to a number, keep the message a KEY. amountMin: z.coerce.number({ error: 'errors.amount.type' }).positive('errors.amount.positive'), // Select sends "all" or a real id — model the sentinel, then normalize it out. statusId: z.union([z.literal(ALL), z.coerce.number().int().positive()]) .transform((v) => (v === ALL ? undefined : v)) .optional(), }); export type InvoiceFilter = z.infer<typeof invoiceFilterSchema>; // one source of truth // Validate anywhere (Server Action included), then localize each issue key. const t = useTranslations(); // from next-intl const parsed = invoiceFilterSchema.safeParse(input); if (!parsed.success) { const messages = parsed.error.issues.map((i) => t(i.message)); // key -> localized string } -
Wire the same schema to the resolver (
@hookform/resolvers/zod) or a Server Action, then handparsed.datato your mutation (seereact-query).
The Select "all" trap
A <Select> cannot hold undefined, so the "no filter" option needs a real string
value like "all". If the field is a plain z.coerce.number(), "all" coerces to
NaN and validation fails on an untouched form. Model the sentinel explicitly
(z.union([z.literal('all'), realValue])) and .transform() it to undefined, so
downstream code sees a clean optional — never a NaN or the literal "all".
Zod 4 caveats (v3 -> v4)
- Error customization is one unified
errorparam:z.string({ error: 'errors.required' }). The v3{ required_error, invalid_type_error }pair is gone. - Read issues from
err.issues(usez.treeifyError(err)/z.flattenError(err)for nested shapes); the old.format()shape changed. z.coerce.number()input type is nowunknown, so it accepts form strings without a cast. Prefer it overz.preprocess(Number, ...).- Email is
z.email()(top-level);z.string().email()is deprecated in v4.
Adapt to your repo
Rename invoiceFilterSchema/Entreprise domain names to yours and confirm your
message-key namespace matches your next-intl catalog (errors.* here). Pick your
sentinel constant ("all", "") and keep it in one place. Verify import { z }
resolves v4 in package.json ("zod": "^4"), not a stale v3.
Gotchas
- A hardcoded English string in a schema message bypasses i18n — always pass a key that exists in every locale catalog (fr/en/ar), then translate at the boundary.
z.coerce.number()turns""into0, not an error — guard required numerics with.min()/.positive()or a preceding non-empty check.- Deriving a hand-written
interfacealongside the schema lets them drift; alwaysz.inferinstead. - Server Actions must re-validate with the same schema — client validation is UX, not
a trust boundary (the DRF layer validates again; see
drf-zod-contract).
See also
drf-zod-contracti18n-rtlreact-query
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.