Skip to content

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 Accept header.

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.

import { entity } from 'actor-ts/http';
interface 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:

  1. Reads req.headers['content-type'].
  2. Picks the matching serializer.
  3. Decodes req.body (a Uint8Array).
  4. 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.

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.

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 ask(registry, { kind: 'list', replyTo: undefined as any });
const { body, contentType } = marshal(req, data);
return { status: 200, body, contentType, headers: {} };
});

marshal(req, value) picks a serializer from req.headers['accept']:

  • application/cbor (or application/x-cbor) → CborSerializer
  • application/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.

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, '');

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.

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.

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'.

del(async (req) => {
// No body to decode — just do the work and return 204.
await ask(registry, { 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.

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.

The framework doesn’t currently support streaming response bodies in the route DSL — HttpResponse.body is materialized in memory before write. For SSE or chunked responses, use the SseActor or drop down to the backend’s native streaming API.