Serialization and Codecs
Default body codecs and custom serialization for clients and server adapters
HTTP values travel through URL strings, headers, or request and response bodies. These rules are shared across routes, clients, and server adapters. See the Route Builder for declaring the interface and Schemas and Validation for validation boundaries.
Path, Query, and Headers
The client stringifies scalar values. The server passes HTTP strings to request schemas, which perform any required coercion.
| Location | Client value shape | Wire representation |
|---|---|---|
| Path params | Object of string, number, or boolean values | Each value is URL-encoded into its path segment |
| Query | Object of scalars, scalar arrays, or optional values | Scalars use key=value. Arrays use repeated key[]=value |
| Headers | Object of scalars or optional values | String values under lowercase header names |
Undefined query and header values are omitted. Missing required path params
fail on the client. Nested query objects are unsupported. A flat GET .input()
uses the same query encoding as .query().
The server reconstructs tag[]=one&tag[]=two as { tag: ["one", "two"] }.
A single tag[]=one remains an array. Repeated scalar keys such as
tag=one&tag=two keep the last value.
Declare header schemas with lowercase names. Incoming Node and Fetch header
names are normalized to lowercase and uppercase schema keys do not match them.
Declare request content types through body options rather than a
content-type request header.
Bodies and Content Types
Body codecs serialize values into HTTP bodies and deserialize HTTP bodies into
values. .input(), .body(), .output(), and .response() default to
application/json. Specifying the contentType option can select another media type.
Schemas operate on values before encoding and after decoding.
Outgoing codecs are selected from the declared content type. Incoming codecs
are selected from the received Content-Type header. Matching uses the lowercase
base media type without parameters, so application/json; charset=utf-8 matches
the JSON codec. A client response without a content-type header or body yields
undefined.
For a list of request content types, select one in the client’s per-call options:
await client.files.upload(bytes, { contentType: "image/png" });
For a list of response content types, the handler selects one with its returned
contentType. The schema and codecs must agree on the values represented by
each selected type.
Streaming
Async iterable outputs use application/x-ndjson: each item is JSON-encoded on
its own line. The client incrementally parses those lines and yields items
through an async iterable.
Inferred streams, .streamOutput(), and .streamResponse() use the same
framing. Streams bypass bodyCodecs. Declared item schemas validate on the
server and optionally on the client with validateResponses.
Fetch Client Defaults
Client Request Serialization
| Content type | In: client value | Out: body on the wire |
|---|---|---|
application/json or any +json media type |
JSON value | UTF-8 JSON text |
application/x-www-form-urlencoded |
URLSearchParams |
URL-encoded form |
multipart/form-data |
FormData |
Multipart form. Fetch supplies the boundary and content-type header |
text/* |
string |
Text |
| Any other media type | Blob or Uint8Array |
Binary body |
Client Response Deserialization
| Content type | In: response body | Out: client value |
|---|---|---|
application/json or any +json media type |
JSON | Parsed JSON value from Response.json() |
application/x-www-form-urlencoded |
URL-encoded form | URLSearchParams from Response.text() |
multipart/form-data |
Multipart form | FormData from Response.formData() |
text/* |
Text | string from Response.text() |
| Any other media type | Binary body | Blob from Response.blob() |
Client response validation runs after deserialization when validateResponses
is enabled. See Schemas and Validation.
Server Adapter Defaults
Servers deserialize incoming request bodies before request validation and serialize outgoing response bodies after response validation.
Server Response Serialization
| Adapter | Content type | In: handler value | Out: body on the wire |
|---|---|---|---|
| All adapters | application/json or any +json media type |
JSON value | UTF-8 JSON text |
| All adapters | application/x-www-form-urlencoded |
URLSearchParams |
URL-encoded form |
| Fetch, Hono | multipart/form-data |
FormData |
Multipart form. Fetch supplies the boundary and content-type header |
| All adapters | text/* |
string |
UTF-8 text |
| Fetch, Hono | Any other media type | Blob or Uint8Array |
Binary body |
| Node HTTP, Express, Fastify, NestJS | Any other media type | Blob, Uint8Array (including Buffer), or Node Readable |
Binary body. Native values are sent using the adapter’s response API |
Binary fallback serializers reject other values. Use a custom serializer to supply another body type supported by the adapter.
Server Request Deserialization
| Adapter | Content type | In: request body | Out: value for validation |
|---|---|---|---|
| Fetch, Hono, Node HTTP | application/json or any +json media type |
JSON | Parsed JSON value |
| Fetch, Hono, Node HTTP | application/x-www-form-urlencoded |
URL-encoded form | URLSearchParams |
| Fetch, Hono, Node HTTP | multipart/form-data |
Multipart form | FormData |
| Fetch, Hono, Node HTTP | text/* |
Text | string |
| Fetch, Hono, Node HTTP | Any other media type | Binary body | Blob |
| Express | Types handled by configured middleware | Body parsed by Express middleware, such as express.json() |
req.body |
| Fastify | Types handled by configured content-type parsers | Body parsed by Fastify | request.body |
| NestJS | Types handled by the underlying Express or Fastify adapter | Body parsed by the underlying framework | The request’s body value |
For Express, Fastify, and Nest, configure the framework’s request parsers or supply a custom deserializer for the content types your routes accept.
Fetch, Hono, and Node limit built-in request decoding to 1 MiB by default.
Set requestBodyLimit to change the limit. Malformed bodies returns 400 Response and
oversized bodies return 413 Response.
Override A Codec
Pass bodyCodecs to initClient(), registerRoutes(), createRouteHandler(),
or RestRpcModule.forRoot(). Each codec has a match(mediaType) function and
optional serialize and deserialize functions. Custom codecs run before the
defaults. The first matching implementation wins independently for each
operation, so overriding only serialization keeps the default deserializer.
Both operations can be asynchronous.
For example, this codec uses Uint8Array values for binary requests and responses:
import { initClient, type BodyCodec } from "@rest-rpc/core";
import { createRouteHandler } from "@rest-rpc/fetch";
import { api } from "./contract";
import { routes } from "./routes";
const binaryCodec: BodyCodec<Request | Response> = {
match: (mediaType) => mediaType === "application/octet-stream",
serialize: (value) => {
if (!(value instanceof Uint8Array)) {
throw new TypeError("Expected Uint8Array body");
}
return { body: value };
},
deserialize: async (source) => new Uint8Array(await source.arrayBuffer()),
};
const client = initClient(api, {
baseUrl: "https://api.example.com",
bodyCodecs: [binaryCodec],
});
const handler = createRouteHandler(routes, {
bodyCodecs: [binaryCodec],
});
Use schemas that accept the decoded values, and configure compatible codecs on
both ends. For other server adapters, adapt deserialize to the native request
source listed below and return a serialized body supported by that
adapter’s response API.
Custom Server Serializer Delivery
The adapter applies the response status and headers, then delivers
serialized.body as follows. A custom serializer must return a value accepted
by this delivery path. Its result does not go through the default serializers.
| Adapter | What happens to serialized.body |
Requirements for custom serializers |
|---|---|---|
| Fetch, Hono | Creates and returns new Response(serialized.body, { status, headers }) |
Return a Fetch response body, such as a string, Blob, FormData, byte array, or Web ReadableStream. A plain object must be encoded first. |
| Node HTTP, Express | If the body is a Node Readable, awaits pipeline(serialized.body, res); otherwise calls res.end(serialized.body) on the server response |
Return a Node Readable to stream, or a string, Buffer, or Uint8Array to end the response. |
| Fastify | Calls reply.send(serialized.body) on the Fastify reply |
Return a value accepted by reply.send(). Fastify may perform further serialization, for example when given a plain object. Return encoded bytes when the codec must control the exact payload. |
| NestJS | Returns serialized.body from the route interceptor for Nest to send |
Return a value Nest can handle. Plain objects go through Nest’s serialization. For binary or streamed delivery, return a StreamableFile from your serializer. |
Serializer Result
serialize(value, declaredContentType) receives the full selected content type,
including parameters, and returns { body, headers?, contentType? }:
bodymust be usable by the target Fetch or server response API.headersadds headers. Declared request or response headers take precedence. Codec headers cannot containContent-Type,Content-Length, orTransfer-Encoding(case-insensitively).- Omitting
contentTypepreserves the declared content type. A string may change parameters but must keep the same base media type.nullomits the explicit content-type header, allowing Fetch to supply it for bodies such asFormData.
Custom Deserializer Sources
deserialize(source) returns the value to validate and receives the native
source for the configured entry point:
| Entry point | Source |
|---|---|
| Fetch client | Fetch Response |
| Fetch server | Fetch Request |
| Hono | HonoRequest (c.req) |
| Node HTTP | IncomingMessage |
| Express | Express Request |
| Fastify | FastifyRequest |
| NestJS | Underlying Express or Fastify request, typed as unknown |