Zod Schema Architecture
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/frontend/references/zod-schemas.md |
| Description | Not specified |
Source Content
Zod Schema Architecture
Make Zod the single source of truth for types, validation, OpenAPI generation, form resolvers, and env parsing. Absorbed from the former zod-schema-architect skill. One schema per entity → inferred TS type, validated input, generated OpenAPI, form resolver, derived DB shape. Duplication is the bug.
Use for
- Anywhere data crosses a trust boundary (request, response, env, config, DB row).
- Duplicate types between client and server packages.
- New domain entity being added to
packages/contracts. - Form schemas defined twice (validator + TS interface).
- Migrating hand-written interfaces to inferred Zod types.
Don’t use for
- Endpoint shape, versioning, pagination →
api-designerskill. - SQL schema / migrations →
database-designerskill. - Form UX / accessibility of error display → React component work (see
references/react.mdin this skill).
How it works
- Map domain entities — list nouns (User, Org, Project, Invoice); one schema per noun in
packages/contracts/src/<entity>.ts. - Brand all IDs —
z.string().uuid().brand<'UserId'>()soOrgIdcan never be passed whereUserIdis wanted. - Derive variants with
.pick / .omit / .partial / .extend— never re-write the same shape. - Discriminate unions with
z.discriminatedUnion('kind', […])for better errors and exhaustiveness. - Validate only at boundaries — handler entry/exit, env loader, queue boundary. Internal calls trust the inferred types.
- Generate OpenAPI —
extendZodWithOpenApi(z)+OpenApiGeneratorV31;.openapi('User', { description })on each schema. - Wire forms & env —
zodResolverfor RHF / TanStack Form;env.tsparsesprocess.envat boot, throws on invalid.
Examples
- “Add a
Projectentity to our contracts package” → addpackages/contracts/src/project.tswith a brandedProjectId, the schema, the inferred type, and an.openapi('Project', …)annotation — re-exported fromindex.ts. - “We have a TS interface for
Userand a Zod schema — consolidate” → delete the interface, infer from the schema, fix every import to use the inferred type, and add brand types where bare strings were sneaking through. - “Validate our env vars at boot” → write
env.tswith a Zod schema that parsesprocess.envand throws on invalid — no silent string fallbacks. - “Generate OpenAPI from these schemas” → wire
extendZodWithOpenApi(z)+OpenApiGeneratorV31, annotate every schema with.openapi(...), and emit the spec at build time.
Self-rubric
- One schema per entity, not parallel TS interface + Zod schema.
- Branded IDs — no bare
stringfor entity references. - Discriminated unions for any variant type with a tag field.
- Validation at boundaries only, not in deep internal helpers.
- No
z.any()/z.unknown()at a boundary without a written justification. - Generated OpenAPI compiles if the contract is consumed externally.
- Validated:
scripts/check_schema_boundary.sh <dir>exits 0 — every<Entity>Schemahas a matchingz.inferexport and noz.any()escape hatch.
scripts/check_schema_boundary.sh <dir> confirms every exported <Entity>Schema has a matching z.infer<typeof <Entity>Schema> type export, and flags any z.any() usage.
Constraints (MUST / MUST NOT)
- MUST: schemas exported as
<Entity>Schema, inferred type as<Entity>; entity files re-exported frompackages/contracts/src/index.ts. - MUST NOT: declare a TS interface alongside a Zod schema for the same shape; re-validate already-parsed data inside the service layer.