TypeScript + Zod Validation
Runtime validation at boundaries with Zod schemas and inferred types.
# CLAUDE.md — TypeScript + Zod Validation
## Where to validate
- Validate at every **boundary**: HTTP request, HTTP response (third-party), DB row → object, env vars, message bus.
- Don't validate inside business logic — by then, types are guaranteed.
- One schema per shape. Co-locate with the code that consumes it.
## Schema basics
- Schemas live in `lib/validators/<feature>.ts`.
- Infer types from schemas — never declare types and schemas separately:
```ts
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
})
export type User = z.infer<typeof UserSchema>
```
- One source of truth: schema → type. If you find yourself writing the type by hand, you're probably duplicating.
## Parsing vs safeParsing
- Use `.parse()` when invalid input is unrecoverable (env vars at boot, internal contracts).
- Use `.safeParse()` at HTTP boundaries — return a typed error response on failure. Don't let zod throw across an HTTP handler.
## Composition
- Build complex schemas from smaller ones:
```ts
const Address = z.object({ street: z.string(), city: z.string() })
const User = z.object({ ..., address: Address })
```
- Reuse schemas across requests/responses with `.pick`, `.omit`, `.partial`, `.required`. Don't redeclare related shapes.
## Coercion & transforms
- `z.coerce.number()` for query strings (everything's a string in URLs).
- `.transform(...)` for derived shapes — but the input and output types should both be intentional.
- `.refine(...)` for cross-field rules. The error path goes in the second arg.
## Error messages
- Set `errorMap` once at module scope to localize / standardize messages.
- For form validation, use `z.flatten(error)` and feed to your form library. Don't write custom serializers.
## Performance
- Compile schemas once at module scope. Don't construct them inside request handlers.
- For large unions, prefer discriminated unions (`z.discriminatedUnion("type", [...])`) — they're orders of magnitude faster than plain `z.union`.
## Env vars
- One `env.ts` parses `process.env` at boot:
```ts
const Env = z.object({
DATABASE_URL: z.string().url(),
SECRET_KEY: z.string().min(32),
NODE_ENV: z.enum(["development", "production", "test"]),
})
export const env = Env.parse(process.env)
```
- Crash on invalid env. Don't run with bad config.
## Don't
- Don't use Zod for runtime checks of internal data after parsing once. The type is the guarantee.
- Don't catch zod errors and rethrow as generic `Error` — you lose the rich `.errors` structure.
- Don't define the type *and* the schema. The schema generates the type.
- Don't validate the same data twice — once at the boundary, once internally. That's a smell that the boundary moved.
Other TypeScript templates
Next.js App Router + TypeScript Rules
Server Components, Server Actions, and TypeScript discipline for the Next.js App Router.
Next.js + Server Actions + Shadcn UI
Forms with Server Actions, Zod validation, and Shadcn UI primitives.
Next.js + Supabase Auth
Auth flows with Supabase SSR, cookies, and Row Level Security policies.
Next.js + Tailwind + Prisma Stack
Full-stack Next.js with Prisma ORM, Tailwind CSS, and Postgres.
Next.js SEO + Metadata Rules
Metadata API, sitemap, robots, OG images, and structured data done right.