Fundamentals
Validation and serialization
Choose how values such as dates and URLs travel between your client and server.
On this page
JSON has no Date type. If a handler returns a date, the client normally receives a string. A codec lets you choose to receive a Date instead.
Start with ordinary JSON, then add validation or conversion when the values in your application need it. The examples below make that change one step at a time.
Suppose an endpoint accepts a date and returns the following day. The smallest contract uses ordinary TypeScript types:
const tomorrow = route.post('/tomorrow', {
body: request.json<{ at: string }>(),
responses: {
200: response.json<{ at: string }>(),
},
})This is type-only mode. The client sends a string, the handler receives a string, and the response contains a string.
Use type-only declarations for already-trusted values or when another layer validates them.
When the value comes from an untrusted request, replace the type argument with a Standard Schema:
+import { z } from 'zod'
const tomorrow = route.post('/tomorrow', {
- body: request.json<{ at: string }>(),
+ body: request.json(z.object({ at: z.iso.datetime() })),
responses: {
- 200: response.json<{ at: string }>(),
+ 200: response.json(z.object({ at: z.iso.datetime() })),
},
})The application types have not changed—at is still a string on both sides. What changed is the boundary:
- malformed dates are rejected before the handler runs;
- an invalid response is caught before it leaves the server;
- TypeScript now infers the types from the schemas.
Zod is used here because it is familiar, not because @hulla/api requires it. Any Standard Schema compatible library works.
Validation can also transform a wire value for the handler. Change the request schema from string → string to string → Date, and reverse that direction for the response:
const tomorrow = route.post('/tomorrow', {
- body: request.json(z.object({ at: z.iso.datetime() })),
+ body: request.json(
+ z.object({
+ at: z.iso.datetime().transform((value) => new Date(value)),
+ }),
+ ),
responses: {
- 200: response.json(z.object({ at: z.iso.datetime() })),
+ 200: response.json(
+ z.object({
+ at: z.date().transform((value) => value.toISOString()),
+ }),
+ ),
},
})Now the handler works with Date without doing manual parsing:
tomorrow: ({ body, response }) => {
const at = new Date(body.at.getTime())
at.setUTCDate(at.getUTCDate() + 1)
return response(200, { at })
}The HTTP client still sends and receives ISO strings. A one-way transform changes the value for the side that runs that schema; it does not define a reversible client representation.
Use this when rich values belong inside the server but the client should stay close to HTTP.
If both the client and server should use Date, describe both directions with a codec:
import { codec } from '@hulla/api'
import { z } from 'zod'
export const dateCodec = codec(
z.object({ at: z.iso.datetime() }),
z.object({ at: z.date() }),
{
decode: ({ at }) => ({ at: new Date(at) }),
encode: ({ at }) => ({ at: at.toISOString() }),
}
)Then replace the directional transforms with the shared codec:
+import { dateCodec } from './date-codec'
const tomorrow = route.post('/tomorrow', {
- body: request.json(
- z.object({
- at: z.iso.datetime().transform((value) => new Date(value)),
- }),
- ),
+ body: request.json(dateCodec),
responses: {
- 200: response.json(
- z.object({
- at: z.date().transform((value) => value.toISOString()),
- }),
- ),
+ 200: response.json(dateCodec),
},
})The wire format remains JSON with an ISO string. The client and handler now both use Date:
const result = await api.tomorrow({
body: { at: new Date('2030-01-01T12:00:00.000Z') },
})
result.body.at // DateA codec is the right choice when the conversion belongs to the contract itself. It keeps callers from repeating new Date(...) and toISOString() around every request.
| Choice | Client value | Handler value | Runtime check |
|---|---|---|---|
| Type argument | string | string | No |
| Schema | string | string | Yes |
| Directional transform | string | Date | Yes |
| Codec | Date | Date | Yes |
A practical default is:
- Use a schema for untrusted input.
- Add a transform when only the server needs a richer value.
- Reach for a codec when both sides should share that richer value.
Next, see where these declarations run in Request and response data, or continue to Error handling.