4.4.2. Every input that crosses the HTTP boundary is parsed by a schema from the module’s *.schema.ts before any other code reads it.
Where parsing happens
Controllers parse. Services receive typed values and trust them.parse throws a ZodError. Express 5 forwards the rejection to errorMiddleware, which answers:
422. There is no validation middleware and no wrapper. The pattern is one schema.parse(...) per input source at the top of the handler.
Naming
Each module declares its own
idParam. It is one line and keeps modules independent.
Patterns used in the codebase
Coerce query strings, not bodies
req.query is strings, so query schemas use z.coerce:
One value or many in a query
A repeated query key arrives as an array, a single one as a string. Normalize with a union and a transform:null and undefined mean different things
Update bodies are partial, so every field is .optional(). A field that can be cleared is also .nullable():
- Key absent: leave the stored value alone.
- Key present with
null: clear it.
exactOptionalPropertyTypes. A property typed x?: string does not accept an explicit undefined. That is why repository code builds objects with conditional spreads instead of passing possibly undefined values through:
Defaults in the schema
Defaults belong in the schema so the inferred type is non optional for the service:durationUnit: durationUnit.default('MONTHS'), active: z.boolean().default(true).
Bounded arrays
Bulk endpoints cap their arrays in the schema.assignProductBulkBody.clientIds is .min(1).max(500). Add a bound to every array a client can send.
Enums
Usez.enum([...]) with string literals that match the Prisma enum. Do not write TypeScript enums. Shared constants such as StudioRole and ErrorCode are as const objects with a derived union type in @perform/types.
Dates
z.coerce.date() turns an ISO string into a Date. For a date only string such as 2026-08-01, that is UTC midnight. Columns that hold a civil day rely on that. See Time zones and day keys.
Stored JSON
JSON columns are validated on the way out as well as on the way in.readCoachPermissions runs coachPermissions.safeParse(stored) and falls back to a safe value when the stored blob does not match. messagesOf does the same for notification messages. Never cast a JSON column straight to a type.
Parsing with safeParse
Use safeParse when a failure is not a client error or needs a custom response: env parsing in defineEnv, stored JSON, and the public waitlist route, which answers a bare 400 without details on purpose.
Semantic rules live in the service
Zod checks shape. A rule that needs the database or depends on other fields in context belongs in the service and throwsAppError:
- “The linked form must exist in this studio and be an onboarding form” is a service rule (
assertForms). - “
namemust be at least one character” is a schema rule.
normalizeSetting in lib/notification-messages.ts returns { issues, value } with codes such as tooManyMessages, titleRequired and offsetOutOfRange so the settings screen can mark each field.
Lanes with different validation output
/v1/automationturns aZodErrorinto a single sentence. See Responses and errors./v1/public/signanswers400with a fixed Hebrew message andcode: 'VALIDATION'.- MCP tools validate with the tool’s
inputSchemain the MCP SDK before the HTTP call is made. The automation lane validates again.
Env and tool schemas
Zod is also the schema language for:- Environment variables (
apps/core-api/src/config.ts). - LLM structured output.
generateObjectfrom the AI SDK takes a Zod schema, for exampleanalysisSchemainai/nutrition-analyzer.ts. - MCP tool inputs (
packages/mcp-tools/src/tools.ts)..describe(...)text on each field is what the model reads. - oRPC procedure inputs in
@repo/api. - Generated model schemas in
@repo/database(prisma/zod/), produced byprisma-zod-generator.