Marshalling
Two halves to the marshalling story:
- Request side — decode the raw byte body into a typed value
based on
Content-Type. - Response side — encode the value back to bytes based on the
client’s
Acceptheader.
For most apps both sides are JSON, and you barely think about this. But the framework supports JSON + CBOR built-in, and the extension point is open for custom serializers.
Decoding the request body
Section titled “Decoding the request body”import { entity } from 'actor-ts/http';
type NewOrder = { sku: string; quantity: number; };
post(async (req) => { const order = entity<NewOrder>(req); // ↑ throws HTTP 400 if body is missing or malformed // ↑ throws HTTP 415 if the Content-Type is one nothing decodes // ↑ chooses serializer based on Content-Type: // application/json → JsonSerializer // application/cbor → CborSerializer // application/x-www-form-urlencoded → FormUrlEncodedSerializer // no Content-Type at all → JsonSerializer // ...});The entity<T>(req) call:
- Reads
req.headers['content-type']and reduces it to the bare media type —application/json; charset=utf-8becomesapplication/json. - Picks the matching serializer, or throws
HttpError(415, ...). - Decodes
req.body(aUint8Array). - Returns the decoded value cast to
T.
If the body is missing or malformed, entity throws an
HttpError(400, ...) — the framework catches it and produces a
400 response with the error message.
Unsupported media types
Section titled “Unsupported media types”A Content-Type nothing decodes — text/xml, application/pdf,
multipart/form-data — is a 415, not an attempt to parse the
body as JSON:
{ "error": "Unsupported Content-Type: text/xml", "accepted": [ "application/json", "application/cbor", "application/x-cbor", "application/x-www-form-urlencoded" ]}The same list ships as an Accept response header, which is what
RFC 9110 §12.5.1
defines it to mean in a response: what to send next time.
Two deliberate exceptions to the rejection:
- A missing Content-Type still decodes as JSON. When the
sender states nothing, RFC 9110 leaves the recipient free to
guess — and
HttpClientsets the header only for object bodies, so a string body ships bare. - Structured-syntax suffixes follow their base type.
application/vnd.api+json,application/merge-patch+jsonandapplication/problem+jsonall decode as JSON, per RFC 6839.
Note that the match is on the parsed media type, so naming a
known type inside a parameter — multipart/form-data; boundary=----application/cbor — does not select that serializer.
Form bodies
Section titled “Form bodies”application/x-www-form-urlencoded decodes into a flat record of
strings, so an HTML <form> posts straight into a handler:
post(async (req) => { const form = entity<{ name: string; tags: string | string[] }>(req); // 'name=Ada&tags=a&tags=b' → { name: 'Ada', tags: ['a', 'b'] }});Every value is a string — form encoding carries no types. A
repeated field widens to an array, matching how req.query
already treats a repeated query parameter. Nesting is not
expressible in this format; use JSON when you need it.
Type checking
Section titled “Type checking”The cast is trust-based. entity<NewOrder>(req) doesn’t
validate that the decoded value matches NewOrder — it just
casts the result. For runtime validation, layer a validator on
top:
import { z } from 'zod';
const NewOrderSchema = z.object({ sku: z.string(), quantity: z.number().int().positive(),});
post(async (req) => { const raw = entity<unknown>(req); const parsed = NewOrderSchema.safeParse(raw); if (!parsed.success) reject(Status.BadRequest, 'invalid order shape'); const order = parsed.data; // typed + validated // ...});This is the standard TS pattern — the framework doesn’t bundle a validator, but every common one (zod, valibot, io-ts, etc.) drops in cleanly.
Encoding the response body
Section titled “Encoding the response body”The simplest path is completeJson:
return completeJson(200, { id: 'o-1', status: 'created' });The body is JSON-serialized at write time; Content-Type is set
to application/json; charset=utf-8. Use this for the 95 % case.
For content-negotiated responses, use marshal:
import { marshal } from 'actor-ts/http';
get(async (req) => { const data = await registry.ask({ kind: 'list' }); const { body, contentType } = marshal(req, data); return { status: 200, body, contentType, headers: {} };});marshal(req, value) picks a serializer from req.headers['accept']:
application/cbor(orapplication/x-cbor) →CborSerializerapplication/json(or*/*, or unspecified) →JsonSerializer
Returns { body: Uint8Array; contentType: string } — pass through
to your HttpResponse.
Use marshal when:
- Different clients want different formats. IoT clients on CBOR, browsers on JSON — the same handler serves both.
- Bandwidth matters and CBOR’s compactness is worth the trade.
Otherwise stick to completeJson.
Built-in serializers
Section titled “Built-in serializers”JsonSerializer
Section titled “JsonSerializer”Standard JSON via JSON.stringify / JSON.parse. Bytes are
UTF-8.
import { JsonSerializer } from 'actor-ts/serialization';
const ser = new JsonSerializer();const bytes = ser.toBinary({ hello: 'world' });const back = ser.fromBinary(bytes, '');CborSerializer
Section titled “CborSerializer”CBOR (RFC 8949) — a binary format more compact than JSON for
nested data, with first-class binary support (no base64 wrapping
for Uint8Array fields).
import { CborSerializer } from 'actor-ts/serialization';
const ser = new CborSerializer();const bytes = ser.toBinary({ image: new Uint8Array([0xff, 0xd8, ...]) });Useful when:
- Payloads contain binary data (images, audio chunks).
- Bandwidth matters (CBOR is typically 20-40 % smaller than JSON for the same data).
- Both sides agree on CBOR — JSON is still the better choice for human-debuggable APIs.
Custom serializers
Section titled “Custom serializers”The serializer interface is small:
interface Serializer { toBinary(value: unknown): Uint8Array; fromBinary(bytes: Uint8Array, manifest: string): unknown;}You can plug in your own (Protobuf, MessagePack, etc.) by
implementing this interface — but the HTTP module’s
media-type-to-serializer map is currently a private constant. For
custom serializers in HTTP handlers, do the picking manually. A
type the built-in map does not know is a 415, so branch on it
before calling entity:
import { ProtobufSerializer } from './my-protobuf';
post(async (req) => { const mediaType = (req.headers['content-type'] ?? '').split(';')[0]!.trim(); let value: MyMessage; if (mediaType === 'application/x-protobuf') { value = new ProtobufSerializer().fromBinary(req.body!, '') as MyMessage; } else { value = entity<MyMessage>(req); // JSON / CBOR / form, else 415 } // ...});The cluster wire (between nodes, not HTTP) has no equivalent hook:
it always frames the tagged JSON tree and never consults the
SerializationExtension — see
Serialization overview.
Encoding errors
Section titled “Encoding errors”import { reject, Status } from 'actor-ts/http';
post(async (req) => { const order = entity<NewOrder>(req); // ↑ throws HttpError(400, "Cannot decode body: ...") // automatically caught and converted to a 400 response});The framework catches HttpError and produces a structured response:
{ "error": "Cannot decode body: Unexpected token } in JSON at position 42"}For more specific error responses, reject after a manual decode:
const raw = entity<unknown>(req);const parsed = ValidationSchema.safeParse(raw);if (!parsed.success) { reject(Status.UnprocessableEntity, 'invalid shape', { issues: parsed.error.issues, });}The extra argument lands in the JSON response body as
top-level fields, alongside error: 'invalid shape'.
Common patterns
Section titled “Common patterns”Empty bodies
Section titled “Empty bodies”del(async (req) => { // No body to decode — just do the work and return 204. await registry.ask({ kind: 'delete', id: req.path.split('/').pop(), replyTo: ... }); return complete(Status.NoContent);});entity throws if the body is empty — don’t call it when you
don’t expect a body.
Narrowing the accepted Content-Types
Section titled “Narrowing the accepted Content-Types”entity already answers 415 for anything outside the built-in
set, so you no longer need a guard just to get that. A check is
still worth writing when an endpoint accepts less than the
framework does — a JSON-only API that should refuse CBOR and form
bodies:
post(async (req) => { const mediaType = (req.headers['content-type'] ?? '').split(';')[0]!.trim(); if (mediaType !== '' && mediaType !== 'application/json') { reject(Status.UnsupportedMediaType, 'expected JSON'); } const order = entity<NewOrder>(req); // ...});Note the !== '': a request with no Content-Type at all is
decoded as JSON rather than rejected, so a guard that forgets the
empty case turns away clients that never set the header.
Streaming responses
Section titled “Streaming responses”HttpResponse.body accepts a web ReadableStream<Uint8Array>, and
all three backends stream it straight to the client without
buffering it in memory — Fastify hands the stream to reply.send,
Express pipes it through Readable.fromWeb, and Hono returns it as
a Response body. Return one straight from a handler:
get(async (req) => { const rows = streamReport(); // ReadableStream<Uint8Array> return complete(200, rows, { 'content-type': 'text/csv' });});Without an explicit Content-Type a stream defaults to
application/octet-stream. Streams are one-shot, so don’t wrap a
streaming response in the caching middleware. For SSE, use the
SseActor.
Set Content-Length yourself when you know it. A backend derives
the length from a Uint8Array body but has nothing to measure on a
stream, so without the header the response goes out chunked.
Fastify and Express keep a length you set; Hono hands its
Response to the runtime’s server, and Bun.serve re-frames a
stream as chunked regardless (node:http and Deno.serve keep it).
Static file serving does this for you.
Where to next
Section titled “Where to next”- HTTP overview — the bigger picture: backends, routing, middleware.
- Route DSL —
complete*,reject, full DSL surface. - Serialization overview — the framework-internal serialization layer for cluster wire format.
