zod boundaries
why
- every untrusted input — HTTP payload, provider response, webhook, file, environment — is a shape assumption until parsed.
- unparsed assumption failing = runtime crash deep inside business logic, not clean rejection at the edge.
- named schemas + inferred types keep validated shape and consumer in one declaration — no drift between validation and use.
rule
implement boundary validation with named Zod schemas. preserve the inferred type; never rewrite it as a parallel interface.
boundaries
- HTTP request, query, parameter, and response payloads.
- provider responses, webhooks, files, environment configuration, and sockets.
- persisted data read outside a trusted migration boundary.
boundary workflow
- accept
unknownat the transport, provider, file, or environment edge. - parse with one named schema whose name identifies the consumer shape.
- return the inferred type from the parser; no parallel type assertion or manually synchronized interface.
- map parsed external data to a domain fact only when source and destination have different stable meanings.
- expose typed diagnostic failure at the same boundary when parsing fails.
decide schema ownership
| value | schema owner | reason |
|---|---|---|
| HTTP request/response | route or shared contract module | it is executable transport behavior and API documentation input. |
| provider/webhook payload | adapter boundary | provider vocabulary must not become a domain contract. |
| environment setting | configuration boundary | process startup should fail before partial runtime behavior. |
| persisted record read through a trusted schema/migration | persistence boundary | storage changes need one auditable interpretation. |
| local already-typed computation | no redundant schema | validation adds no authority when the type is produced inside the same trusted boundary. |
failure modes prevented
| shortcut | result |
|---|---|
cast unknown as Domain | malformed data reaches business logic and fails without an input boundary. |
| validate only a field used today | later logic consumes an unchecked part of the same payload. |
| duplicate a schema as an interface | type and runtime validation drift independently. |
| parse inside several consumers | each caller invents a slightly different acceptance rule. |
verification
- run valid and malformed representative values through the schema at its real boundary.
- confirm the inferred type is the only transport type consumers use.
- confirm the diagnostic preserves a safe remediation category.
- confirm provider fields do not escape without an explicit mapper.
do not
- cast
unknowninto a domain type. - duplicate a Zod schema as handwritten transport types.
- validate only after business logic already consumed the value.
good and bad
bad
// route handler accepts requestBody and asserts without validationconst payload = requestBody as CreateWorkflowRunInput;return createWorkflowRun(payload);
- assertion accepts malformed external data without a diagnosable boundary.
- client sends
{ name: null }instead of{ name: "My Workflow" }: type checker thinks it is safe, domain logic crashes on null propagation or produces a broken workflow. - debugging traces backward from the crash, but the entry boundary accepted the bad shape silently.
good
// route handler validates at the boundaryconst payload = CreateWorkflowRunInputSchema.parse(requestBody);return createWorkflowRun(payload);// if requestBody is { name: null }, parse fails immediately with a typed diagnostic// "CreateWorkflowRun: name must be a string"
- parser accepts
unknown, establishes the inferred type, fails where the untrusted value entered the system. - malformed input caught before it reaches domain logic.
- later developers reading the domain function can trust its inputs are validated.