Zod Conventions
SkillCommunicationEsposter Zod schema conventions — z namespace imports and the export-type z.infer form, string normalization via transform+pipe and the constraints-on-the-final-pipe rule, createUniqueArraySchema for arrays, Zod 4 shorthand APIs (z.email/z.int/z.strictObject/z.enum) and the ZodError issue-push form, persisted-data latest-shape-only, tightest-possible numeric constraints, minimal strict input schemas, .default() rules, named ID field schemas, a field several models share as one named schema spread by .shape, refineAtLeastOne, record maps over switch, and Except+ToData for class-typed outputs, plus deep dives on validating untrusted boundary payloads, annotating exported schema consts under --isolatedDeclarations, create*Schema factories / discriminated unions / envelope schemas, and which positions check a mirrored field key (the computed keyof().enum form where nothing else does). Apply when writing Zod schemas. Schemas rendered by Vjsf have extra rules — see the `vjsf` skill.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Zod Conventions skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/zod/SKILL.md and read by ahel’s review.
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/field-key-checks.md— when an object key mirrors another schema's field and the choice is a literal key or the computed[src.keyof().enum.field].
Imports and Inferred Types
- Always the
znamespace export:z.ZodType,z.ZodError. Never named imports likeimport type { ZodType }. - Interface-first (
satisfies z.ZodType<T>) is the default — see~/.claude/rules/zod.md.z.inferis for schemas with no hand-written interface (tRPC input schemas), not for models. Every schema takes it,z.enum(SomeEnum)included — the one-liners are where it goes missing, and there it is what catches a schema pointed at the wrong enum:rg 'z\.enum\([A-Z]\w+\)\s*(;|$)' | rg -v satisfiesfinds them. - 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. - 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 — Always .transform().pipe()
When normalizing a string (trim, lowercase, etc.) before further validation, use .transform(fn).pipe(refinedSchema). Never .overwrite() — inconsistent with the codebase.
Reach for a shared helper before writing the chain by hand, and the choice between the two is only whether the field may be empty:
| Helper | From | Emits | Use for |
|---|---|---|---|
createNameSchema(maxLength) | @esposter/db-schema | .min(1).max(maxLength) after trim | a field that must carry something — a name |
createNormalizedStringSchema(maxLength) | @esposter/shared | .max(maxLength) after trim | optional prose — a topic, a reason, a note |
Which is which is the whole decision, and it is why hand-rolled copies keep appearing: someone who cannot recall whether the helper forces min(1) writes the pipe out instead. If the field has an empty-string default or is .optional(), it is the second one.
z.string().transform(normalizeString).pipe(z.string().min(1).max(MAX));
z.string()
.transform((v) => normalizeString(v).toLowerCase())
.pipe(z.string().min(1).max(MAX));
Consolidate all string constraints (min, max, regex, …) on the single final .pipe() output — never nest pipes when JSON schema output matters. z.toJSONSchema / zodToJsonSchema run with io = "output": for any ZodPipe(A, B) they use B and silently drop constraints on A, so createNormalizedStringSchema(maxLength, base).pipe(z.string().min(1)) emits { minLength: 1 } with the maxLength missing. A helper that nests pipe layers silently drops the constraints declared on the inner ones.
Arrays — Always createUniqueArraySchema
Never call .array() directly unless duplicates are genuinely valid. Use createUniqueArraySchema(schema) from @esposter/shared — it wraps .array() with a uniqueness refine, and all chaining (.min(), .max(), .nullable(), .optional(), .default()) works identically after (Zod 4's .refine() returns the same ZodArray type). For object arrays, pass the uniquely-identifying field name as the second argument:
createUniqueArraySchema(z.string()).max(MAX_READ_LIMIT); // not z.string().array()
createUniqueArraySchema(fooSchema, "id").max(FOO_MAX_LENGTH).default([]);
Exception — duplicates are valid: use plain .array() when the array semantically allows duplicates (positional DOM bounds, the same config at different values, ordered content blocks). Don't add an artificial id field just to force uniqueness.
Zod 4 APIs
- Format validators and numeric refinements are top-level functions — never the Zod 3 chained syntax:
z.email(),z.url(),z.uuid(),z.nanoid(),z.cuid()/z.cuid2(),z.ulid(),z.emoji(),z.base64()/z.base64url(),z.ipv4()/z.ipv6()(notz.string().ip({ version })),z.int()(notz.number().int()),z.iso.date()/.datetime()/.time()/.duration(),z.strictObject({...})(not.strict()),z.looseObject({...})(not.passthrough()). z.uuid()strictly validates RFC 9562/4122 — usez.guid()for permissive "UUID-like" validation.z.enum(MyEnum)directly for TS string enums;z.nativeEnumis Zod 3 only.- A refinement's custom text goes under
error—.refine(check, { error: "…", path: [...] }), never Zod 3'smessage, which still parses and so leaves two spellings of one key in the same tree. Themessagekey inside a pushed issue is a different object and keeps its name. - Never
.addIssue()/.addIssues()on aZodError(deprecated in Zod 4) — push directly:myError.issues.push({ code: "custom", message: "..." }).ctx.addIssue()insidesuperRefineis still valid (it operates on the refinement context, not aZodError).
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. Data that fails to parse resets to a fresh default; the reset is the migration, and the old shape is deleted in the same commit. Standard: apps/web/content/docs/architecture/persisted-data-latest-shape-only.md.
Tightest Possible Constraints
Every field carries the tightest constraint its domain allows — a bare z.number() / z.string() is only correct when the value is genuinely unbounded. Audit each numeric field against what it models, in the app and in packages/* libraries alike:
- Count / quantity / index / byte size (whole, ≥ 0) →
z.int().nonnegative(); a count that can't be zero (frequency, sample count) →z.int().positive(). - Price / rate / duration / timestamp (fractional, ≥ 0) →
z.number().nonnegative(); one that can't be zero (price, multiplier) →z.number().positive(). - Percentage →
z.number().min(0).max(100). - Genuinely signed value (deltas, statistical
average/minimum/maximum/summation, coordinates) → leavez.number()bare.
Rules:
- Integers use
z.int(), neverz.number().int()(Zod 4) and never plainz.number()when the value is whole by definition (counts, indices, byte sizes). - ≥ 0 is
.nonnegative(), > 0 is.positive()— never.min(0)/.min(1)for these; reserve.min(N)for a domain-specific lower bound (usually paired with an upper). - Check the seed/fixture data before choosing — if every real value is strictly positive (prices, effect multipliers), use
.positive(), not the weaker.nonnegative().
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()— never combine.optional().default(value)(.default()already handlesundefined). Only use.default()in schemas whose TS type is a class with actual property defaults (e.g.class Foo { bar = [] }). Never add.default()to a schema thatsatisfies z.ZodType<Interface>— interfaces have no defaults, so schema and type would misalign. Initialise empties explicitly at the call site (new MyClass()or{ steps: [] }). The ban is about a model schema — the shape app code constructs. A schema standing at a boundary states what the boundary accepts, so a field the interface requires may still carry a default there: a tRPC input declaring the field omittable, or a Vjsf form prefilling it. The output type is unchanged either way, which is why both still satisfy the interface. A Vjsf default stays on the shared schema rather than the*Formone, for thesafeExtendreason thevjsfskill gives. -
Shared ID field schemas — always use the named ID schemas (
roomIdSchema,userIdSchema,userIdsSchemafrom@esposter/db-schema) for object fields matching their canonical name. Whole schema is just an ID field → use it directly (const onUpdateSchema = roomIdSchema). Multi-field objects → spread the shape (z.object({ ...roomIdSchema.shape, ...userIdSchema.shape, otherField: ... })). Constrained variants → chain from the shape field (userIds: userIdsSchema.shape.userIds.min(1)). For differently-named fields (targetUserId,actorUserId), useselectUserSchema.shape.iddirectly. A.pick()projection of the row that owns the column keeps that row's own schema — the rule is about fields being assembled into an object, not a reason to split one row's projection into a pick plus a spread. -
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 —namingskill) inshared/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— when an update/patch schema has optional fields and at least one must be provided, userefineAtLeastOnefrom#shared/services/zod/refineAtLeastOne. Never inline.refine((data) => ...). Its key list is read off the schema it guards, never restated as literals: name the updatable fields once as their own schema and passupdatableFooSchema.keyof().options, so a field added to the shape is guarded without a second edit. A literal array is right only where the guarded set is deliberately narrower than the schema's optional fields —updateUserToRoomInput, whose optionaltargetUserIdis a qualifier rather than one of the fields the update must set. -
Record maps over switch statements — when a switch on an enum drives different async operations, prefer
const actionMap: Record<EnumType, (args) => Promise<void>> = {...}andawait actionMap[type](args). Exhaustiveness is enforced by the Record key type; noexhaustiveGuardneeded. -
satisfies z.ZodType<T>with class types — when schema output is plain objects but the interface uses class instances (withtoJSON), useExcept+ToDatato striptoJSONfrom nested classes:export const fooSchema = z.object({...}) satisfies z.ZodType<Except<Foo, "bars"> & { bars: ToData<Bar>[] }>; -
vjsf form schemas — a schema rendered by Vjsf gets a separate
*Forminterface + schema,layout/title meta, ajv keywords, and its own discriminated-union rules: thevjsfskill owns all of it. -
Paginated endpoint schemas — see the
trpcskill (references/read-endpoints.md).
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
zod-esposter- Source
- github.com/esposter/esposter