Fundamentals
Request and response data
Turn URL parameters, headers, and request bodies into values your handlers can use.
On this page
A query such as ?page=2 arrives as text, even if your handler needs a number. Request schemas describe how to turn that input into the value the handler receives.
Use a codec when the client also needs to convert a value before sending it.
Path parameters, query fields, and headers cross HTTP as text. Their wire schemas must accept strings; query arrays and tuples accept repeated strings.
Start with a one-way transform when only the server needs a richer value:
import { response, route } from '@hulla/api'
import { z } from 'zod'
const serverQuery = z.object({
page: z.string().regex(/^\d+$/).transform(Number),
})
const search = route.get('/search', {
query: serverQuery,
responses: { 200: response.empty() },
})The client supplies { page: string }; the handler receives { page: number }. That is often exactly the boundary you want for an HTTP-facing client.
If callers should also use a number, describe the reverse conversion with codec():
import { codec } from '@hulla/api'
import { z } from 'zod'
export const searchQuery = codec(
z.object({ page: z.string().regex(/^\d+$/) }),
z.object({ page: z.number().int() }),
{
decode: ({ page }) => ({ page: Number(page) }),
encode: ({ page }) => ({ page: String(page) }),
}
)Then swap the server-only schema for the codec:
-import { z } from 'zod'
+import { searchQuery } from './search-query'
-const serverQuery = z.object({
- page: z.string().regex(/^\d+$/).transform(Number),
-})
-
const search = route.get('/search', {
- query: serverQuery,
+ query: searchQuery,
responses: { 200: response.empty() },
})Now the client and handler both use { page: number }. HTTP still receives and sends ?page=2; the codec owns the conversion in both directions.
Plain z.number() and z.boolean() are invalid query, path, and header wire schemas because HTTP delivers those values as text. Values are not implicitly stringified. Use a textual one-way transform when the client should remain wire-shaped, or a codec when both applications should use the richer type.
Codec endpoint schemas describe stable representations. Type-changing transforms and coercions belong in decode and encode; same-type normalization such as trimming remains valid when it is safe to apply repeatedly.
Client path parameters cannot encode to . or ..: URL normalization would change the target endpoint. They are rejected before the request is sent.
Query transport is intentionally flat. Scalars use one key and arrays or tuples repeat the key:
?search=Ada&tags=admin&tags=authorOn input, one occurrence is passed to the schema as a string and repeated occurrences are passed as a string array. The schema owns singleton-versus-array normalization when both forms are valid. undefined fields are omitted and explicit empty arrays are rejected. An absent field may still become [] when the schema declares a default.
Nested objects and nested arrays are rejected. There is no built-in bracket parser or JSON query mode: those formats introduce transport policy, ambiguity, and parser-safety concerns that are better kept out of the default contract model.
A naked body schema is JSON shorthand. JSON bodies retain their natural JSON types:
import { defineContract, response, route } from '@hulla/api'
import { z } from 'zod'
const jsonBodyContract = defineContract({
routes: {
createArticle: route.post('/articles', {
body: z.object({
title: z.string(),
count: z.number().int(),
active: z.boolean(),
}),
responses: { 201: response.empty() },
}),
},
})A codec is needed when JSON cannot directly represent the application value, or when client and server should share a transformed value:
import { codec, defineContract, request, response, route } from '@hulla/api'
import { z } from 'zod'
const datedBody = codec(
z.object({ createdAt: z.iso.datetime() }),
z.object({ createdAt: z.date() }),
{
decode: ({ createdAt }) => ({ createdAt: new Date(createdAt) }),
encode: ({ createdAt }) => ({ createdAt: createdAt.toISOString() }),
}
)
const datedBodyContract = defineContract({
routes: {
createArticle: route.post('/articles', {
body: request.json(datedBody),
responses: { 201: response.empty() },
}),
},
})Explicit representations use the request namespace:
import { defineContract, request, response, route } from '@hulla/api'
const representationContract = defineContract({
routes: {
json: route.post('/json', {
body: request.json(),
responses: { 204: response.empty() },
}),
text: route.post('/text', {
body: request.text(),
responses: { 204: response.empty() },
}),
bytes: route.post('/bytes', {
body: request.bytes(),
responses: { 204: response.empty() },
}),
formData: route.post('/form-data', {
body: request.formData(),
responses: { 204: response.empty() },
}),
},
})Each helper also has a representation-appropriate identity schema when the schema is omitted. Content types match by MIME essence, so parameters such as JSON charsets and multipart boundaries do not change the selected representation.
Adapters provide already-extracted native path strings, flat query values, headers, and the selected body representation. Repeated query keys become arrays before Standard Schema validation. The shared executor validates those values without constructing a Fetch Request; framework adapters therefore keep their native request and response lifecycle while reusing the same codec and validator execution.
import { defineContract, request, response, route } from '@hulla/api'
const nativeJsonContract = defineContract({
routes: {
rename: route.post('/rename', {
body: request.json<{ name: string }>(),
responses: {
200: response.json<{ id: string; name: string }>(),
},
}),
},
})A type argument adds compile-time types only. No recursive shape validation or coercion runs. JSON serialization and parsing follow the native transport, including omission of undefined object fields; malformed JSON still fails parsing. In-process calls pass values directly and do not simulate JSON serialization. Use JSON-compatible values across transports.
Pass a Standard Schema when runtime validation is required. Its transforms are explicit conversion instructions. Use a codec when both applications should share a richer representation. No global validation toggle changes these declarations.
Ordinary response schemas validate and transform on the server before serialization. The client receives their parsed output without rerunning the schema. For example, response.json(z.date().transform(date => date.toISOString())) accepts a Date from the handler and returns a string to the client. Use an explicit codec when the client should receive a Date. This rule also applies to response headers, formatted stream items, and declared error data. See value round trips.