Skip to content

Zod Schema Architecture

FieldValue
TypeSkill Resource
Source~/.copilot/skills/frontend/references/zod-schemas.md
DescriptionNot 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-designer skill.
  • SQL schema / migrations → database-designer skill.
  • Form UX / accessibility of error display → React component work (see references/react.md in this skill).

How it works

  1. Map domain entities — list nouns (User, Org, Project, Invoice); one schema per noun in packages/contracts/src/<entity>.ts.
  2. Brand all IDsz.string().uuid().brand<'UserId'>() so OrgId can never be passed where UserId is wanted.
  3. Derive variants with .pick / .omit / .partial / .extend — never re-write the same shape.
  4. Discriminate unions with z.discriminatedUnion('kind', […]) for better errors and exhaustiveness.
  5. Validate only at boundaries — handler entry/exit, env loader, queue boundary. Internal calls trust the inferred types.
  6. Generate OpenAPIextendZodWithOpenApi(z) + OpenApiGeneratorV31; .openapi('User', { description }) on each schema.
  7. Wire forms & envzodResolver for RHF / TanStack Form; env.ts parses process.env at boot, throws on invalid.

Examples

  • “Add a Project entity to our contracts package” → add packages/contracts/src/project.ts with a branded ProjectId, the schema, the inferred type, and an .openapi('Project', …) annotation — re-exported from index.ts.
  • “We have a TS interface for User and 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.ts with a Zod schema that parses process.env and 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 string for 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>Schema has a matching z.infer export and no z.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 from packages/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.

References