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 // ↑ chooses serializer based on Content-Type: // application/json → JsonSerializer // application/cbor → CborSerializer // anything else → JsonSerializer (fallback) // ...});The entity<T>(req) call:
- Reads
req.headers['content-type']. - Picks the matching serializer.
- 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.
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 MIME-to-serializer map is currently a private constant. For custom serializers in HTTP handlers, do the picking manually:
import { ProtobufSerializer } from './my-protobuf';
post(async (req) => { const ct = req.headers['content-type'] ?? ''; let value: MyMessage; if (ct.includes('application/x-protobuf')) { value = new ProtobufSerializer().fromBinary(req.body!, '') as MyMessage; } else { value = entity<MyMessage>(req); // JSON fallback } // ...});For the cluster-wire serialization (between nodes, not HTTP), the
SerializationExtension is the right hook — 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.
Pre-validating Content-Type
Section titled “Pre-validating Content-Type”post(async (req) => { const ct = req.headers['content-type'] ?? ''; if (!ct.startsWith('application/json')) { reject(Status.UnsupportedMediaType, 'expected JSON'); } const order = entity<NewOrder>(req); // ...});Only useful when you want a specific Content-Type rejected explicitly rather than going through the JSON fallback.
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.
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.
