Zod Notes & Considerations (for OpenAPI)
- Query, Params, Headers and FormData always arrive as strings (and files in the case of multipart). Use
z.coerce.*
ortransform(...).pipe(...)
to model real types.- When you use
transform(...)
, finish with.pipe(YourOutputSchema)
so OpenAPI knows the resulting type.- Inputs (request parts) are documented from the pre‑transform shape. Responses are documented from the final schema you pass to
spec.response(...)
(so.pipe(...)
is key).- Some Zod types are not representable in OpenAPI; CrumbJS provides sane fallbacks.
Request parts are strings
In routeConfig
, the query, params, headers, and form‑data / url‑encoded forms arrive as strings. If you declare non‑string primitives directly, validation will fail.
Examples
app.get('/users/:id', ({ params }) => params, {
params: z.object({
id: z.int().positive(), // ❌ an int will never come in HTTP path
}),
});
app.get('/users/:id', ({ params }) => params, {
params: z.object({
id: z.coerce.number().int().positive(), // ✅
}),
});
Even in json bodies the date will not come as Date
instance.
app.post('/', ({ body }) => body, {
body: z.object({
date: z.date(), // ❌ a Date instance never comes from http request. Will fail at validation
}),
});
app.post('/', ({ query }) => query, {
query: z.object({
date: z.iso.date(), // ✅ will accept "2025-08-27", etc.
datetime: z.iso.datetime(), // ✅ will accept "2020-01-01T06:15:00Z", etc.
}),
});
Easiest and better way to handle this
Codecs helpers includes a set of common encode/decode cases. Are zod types so you can use .optional()
.nullable()
.default(value)
as usual
import { codecs } from '@crumbjs/core'
// Dates & Times
codecs.stringDate; // Input string: "2025-08-27" → Output: Date instance
codecs.stringDatetime; // Input string: "2017-07-21T17:32:28Z" → Output: Date instance
codecs.epochSecondsDate; // Input number: 1692873600 → Output: Date (seconds since epoch)
codecs.epochMillisDate; // Input number: 1692873600000 → Output: Date (milliseconds since epoch)
// Numbers
codecs.stringNumber; // Input string: "42.5" → Output: number
codecs.stringInt; // Input string: "42" → Output: integer (number)
codecs.stringBigInt; // Input string: "9007199254740991" → Output: bigint
// URLs
codecs.stringURL; // Input string: "https://example.com" → Output: URL instance
app.get('/users/:id', ({ params }) => params, {
params: z.object({
id: codecs.stringNumber, // ✅
}),
});
Form‑Data & Files
For multipart/form-data
, declare the body type and include files:
app.post('/upload', ({ body }) => body, {
type: 'multipart/form-data',
body: z.object({
date: codecs.stringDate, // ✅ strings -> Date
file: z.file(), // ✅ single file
// files: z.array(z.file()).max(5), // ✅ multiple files (example)
}),
});
For application/x-www-form-urlencoded
, the same string rule applies.
Transforms & .pipe(...)
(tell OpenAPI the output)
transform(...)
changes values at runtime, but OpenAPI can’t infer the post‑transform shape unless you finish with .pipe(...)
pointing to an explicit schema.
// Conceptual example: fetch a user by id string and expose it as a full object
const UserSchema = z.object({
id: z.number().int(),
name: z.string(),
email: z.string().email(),
});
const UserByIdString = z
.string()
.transform(async (val) => {
const user = await db.query.users.findFirst({ where: eq(users.id, val) });
if (!user) throw new NotFound();
return user; // runtime value is a full User
})
.pipe(UserSchema); // ✅ OpenAPI now knows the output is UserSchema
Without
.pipe(UserSchema)
, the documentation would not know the output type of the transform.
Inputs vs Outputs — how docs are built
- Inputs (
params
,query
,headers
,body
) are documented from the input shape you define inrouteConfig
. That is, pre‑transform. Usez.coerce.*
to keep the doc honest. - Responses are documented from the schema you pass to
spec.response(...)
. If you transform, pipe it to an explicit output schema so the doc reflects the final shape.
Example
app.post('/io', () => '', {
body: z.object({
userId: z
.string()
.transform((val) => ({ userId: Number(val) }))
.pipe(z.object({ userId: z.number() })),
}), // 📘 Body is documented as { userId: string } because inputs are pre‑transform
responses: [
spec.response(
200,
z.object({
userId: z
.string()
.transform((val) => ({ userId: Number(val) }))
.pipe(z.object({ userId: z.number() })),
}),
), // 📘 Response is documented as { userId: number } thanks to .pipe(...)
],
});
This pattern is extremely useful to re‑use schemas and keep docs accurate.
Unrepresentables (OpenAPI fallbacks)
Not all Zod types have a faithful OpenAPI representation. CrumbJS applies the following fallbacks when generating the spec:
z.bigint(); // -> { type: 'integer', format: 'int64' }
z.int64(); // -> { type: 'integer', format: 'int64' }
z.symbol(); // -> {}
z.void(); // -> {}
z.date(); // -> { type: 'string', format: 'date-time' }
z.map(); // -> {}
z.set(); // -> {}
z.transform(); // -> {} unless you finish with `.pipe(...)`
z.nan(); // -> {}
z.custom(); // -> {}
When OpenAPI can’t express a type, it’s rendered as an empty schema
{}
. Prefer explicit, representable shapes at your boundaries (e.g., turnmap/set
into arrays of{ key, value }
objects, orunique
arrays).
Gotchas & best practices
- ✅ Use codecs helper or
z.coerce.*
for request parts; reservetransform(...).pipe(...)
for heavier logic. - ✅ Use
.pipe(...)
whenever you need docs to reflect a post‑transform output. - ✅ For multipart routes, set
type: 'multipart/form-data'
and usez.file()
. - ✅ Keep inputs simple and representable; complex runtime types belong inside your handler/service layer.
- ❌ Avoid
z.date()
z.number()
etc directly in query/params/headers/forms. Use helpers or zod iso dates. - ❌ Avoid types OpenAPI can’t represent at the boundary (e.g.,
Map
,Set
,symbol
, rawbigint
).
If you hit an edge‑case, open an issue with a minimal repro and we’ll extend the mapper or suggest a boundary‑friendly pattern.