zod
Esposter Zod schema conventions — z namespace imports, no optional+default combos, generic schemas with create* functions, vjsf form schemas, and satisfies with ToData for class types. Apply when writ
SKILL.md.claude/skills/zod/SKILL.md
GitHubZod Conventions
Settled — do not re-propose
- A lint rule for the inherited-key rule —
.safeExtendlegitimately adds new fields as well as layering over existing ones, so nothing syntactic separates the key that must match from the key that must not;references/field-key-checks.mdcarries the measured table of which positions check a key. - A ban on
.extend()— Tiptap's.extendand Zod's share one method name and no syntactic rule tells them apart. - A ban on
export type X = z.infer<…>— allowed in the narrow composed-schema case, which needs the judgement.
Deep Dives
references/boundary-payloads.md— when parsing runtime data that crosses a trust boundary: EventGrid data, a queue message, a webhook body, subprocess stdout, a committed config file.references/isolated-declarations.md— when exporting a schema const from apackages/*library, or a consumer's...someSchema.shapespread breaks against the published package.references/generic-factories.md— when a schema needs a type parameter, forms a discriminated union, wraps many payloads in one envelope, or shares a field with only some union members.references/string-normalization.md— when a string is trimmed or lowercased before validation: the two shared helpers and the hand-written chain.references/numeric-constraints.md— when adding a numeric field: which constraint its meaning earns.references/field-key-checks.md— when an object key mirrors another schema's field or a table's entry, and the choice is a literal key or the computed[src.keyof().enum.field].references/arrays.md— when a schema holds an array.references/zod-4-apis.md— when writing a format validator, an enum schema, a refinement's error text, or an issue on aZodError.references/defaults.md— when a field is about to take.default().references/id-fields.md— when a schema has an id field.references/refine-at-least-one.md— when an update schema needs at least one of its optional fields.references/class-types.md— when a schema satisfies an interface holding class instances.
Imports and Inferred Types
- Always the
znamespace export:z.ZodType,z.ZodError— a named import beside it is ano-restricted-syntaxerror. - Interface-first (
satisfies z.ZodType<T>) is the default: a model schema takes it.z.inferis only for a type that genuinely must be derived, such as a tRPC input schema with no hand-written interface. A barez.enum(SomeEnum)orz.discriminatedUnion(…)declarator is ano-restricted-syntaxerror in source, since a missingsatisfiesthere lets a schema point at the wrong enum or a variant drift from its interface. - When you do need infer, always
export type X = z.infer<typeof xSchema>— neverinterface X extends z.infer<typeof xSchema> {}. The extends form trips oxlintimport/namespace("infer" not found in imported namespace), because theznamespace can't be resolved inextendsposition. - One interface and one schema per model file, the schema beside the interface it satisfies; a second schema in the same file is a split.
- Declare the
typedirectly beneath its schema and reference it by name — the alias lives next to theconst xSchema = z.object({...})it derives from, and use sites refer toX. Don't inlinez.infer<typeof xSchema>at the use site.
String Normalization — references/string-normalization.md
.transform(fn).pipe(refinedSchema), never .refine() for normalisation, with every string constraint on the single final .pipe() output. Reach for createNameSchema(maxLength) (@esposter/db-schema, required) or createNormalizedStringSchema(maxLength) (@esposter/shared, optional prose) before writing the chain by hand — which is which is that page.
Arrays — Always createUniqueArraySchema
Never .array() directly — createUniqueArraySchema(schema, key?) from @esposter/shared, unless duplicates are genuinely valid (references/arrays.md).
Zod 4 APIs
Zod 4's top-level builders (z.email(), z.int(), z.strictObject), z.enum(MyEnum), a refinement's text under error, and an issue pushed onto error.issues (references/zod-4-apis.md).
Validate, Never Cast
Runtime data crossing any trust boundary (EventGrid event.data, queue messages, webhook bodies, subprocess stdout, committed config files) gets a co-located Zod schema parsed at the edge — never x as unknown as SomeType or hand-rolled guards + casts. Full rules: references/boundary-payloads.md.
Persisted Data — Latest Shape Only
Schemas for persisted client-authoritative data (save blobs, localStorage state) and Azure Table entities model only the latest shape — no legacy union arms, no .default()s covering fields older data lacks, no migration code, no read-side inference of a field a pre-change row lacks. A parse failure resets the data to a fresh default; the reset is the migration, and the old shape is deleted in the same commit. Standard: openspec/specs/architecture/persisted-data-latest-shape-only/design.md.
Tightest Possible Constraints — references/numeric-constraints.md
Every field carries the tightest constraint its domain allows — a bare z.number() / z.string() is only correct where any value is valid. On a tRPC input the string and array half is enforced: apps/web/server/trpc/routers/index.test.ts walks every procedure's input as JSON Schema and fails on a string with no length, format or pattern and an array with no item cap. Integers are z.int(), ≥ 0 is .nonnegative(), > 0 is .positive(), and the seed data says which; the mapping from a field's meaning to its constraint is that page.
Schema Rules
- Minimal strict input schemas — model the exact case being implemented now. Prefer required fields over optional +
.refine()when only one flow is supported; split future variants into separate schemas/procedures later. Use.refine()only for cross-field rules that can't be represented structurally. - Schema must match its type exactly — if a field is
FooType, usefooTypeSchema, never inlinez.union([barSchema, ...]). Every named type has exactly one named schema; never reconstruct a union inline. .default()only on a class-typed model or at a boundary, never with.optional()and never on a schema satisfying an interface (references/defaults.md).- Named ID field schemas (
roomIdSchema,userIdSchema,userIdsSchema), spread by.shapeinto a multi-field object (references/id-fields.md). - Another resource's id is declared through
createResourceLinkSchema(ResourceLinkType.X)— a plain uuid in that place is a reference the resource-link index cannot see (openspec/specs/architecture/resource-links/design.md). - A field several models share is one named interface + schema — when multiple models share a field (e.g.
bar), define a singleBar/barSchema(named after the capability — thenamingskill, "TypeScript & Interfaces") inapps/web/shared/models/entity/and spread the schema's.shapeinto each model schema. Don't add.default(...)to the shared schema — each implementing class declares its own default as a class field and adds it at the schema call site. - An object key mirroring another schema's field is checked nowhere in two positions —
.pick()/.omit()on a genericz.ZodObjectand.safeExtend({ … })— so there the key is the computed[src.keyof().enum.field]; everywhere else a literal key is correct and the computed form is noise (references/field-key-checks.md). - A spread of
.shapecarries fields and nothing else —.catchall()and every other whole-object modifier stays behind, where.extend()would have brought it. Nothing in the types changes when it goes missing, so the derived schema just starts stripping keys the base kept: re-declare the modifier on the derived schema and pin it with a test, because the typecheck will not. refineAtLeastOne, its key list read off the schema it guards, never restated (references/refine-at-least-one.md).- An enum dispatching per-case logic is a map keyed by it, not a switch — the
typescriptskill (references/control-flow.md). - A schema for a class-typed interface strips
toJSONwithExcept+ToData(references/class-types.md). - Schema form schemas — a schema a
UiSchemaFormrenders gets a separate*Forminterface and schema,layoutand title meta, a refinement for a cross-field rule, and its own union rules: theui-libraryskill'sreferences/schema-forms.mdowns all of it. - Paginated endpoint schemas — see the
trpcskill (references/read-endpoints.md).