Skip to main content

Zod Notes & Considerations (for OpenAPI)

  • Query, Params, Headers and FormData always arrive as strings (and files in the case of multipart). Use z.coerce.* or transform(...).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 in routeConfig. That is, pre‑transform. Use z.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(...)
],
});

Generated openapi

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., turn map/set into arrays of { key, value } objects, or unique arrays).


Gotchas & best practices

  • ✅ Use codecs helper or z.coerce.* for request parts; reserve transform(...).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 use z.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, raw bigint).

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.