Defining Actions
Root domains, child domains, action schemas, serialization, and thrown errors.
Root and child domains
Section titled “Root and child domains”First make a root domain — an empty top-level namespace with no actions of its own. Then hang child domains off it, and put your actions in those. Both sides import all of this from shared code.
We prefix action domains with act_ (so act_user, act_lobby) the same way error domains use err_. It keeps names predictable and makes it obvious at a glance what kind of thing you’re looking at.
import { createActionRootDomain, actionSchema } from "@nice-code/action";import * as v from "valibot";
export const act_app = createActionRootDomain({ domain: "act_app" });
export const act_user = act_app.createChildDomain({ domain: "act_user", actions: { getUser: actionSchema() .input({ schema: v.object({ userId: v.string() }) }) .output({ schema: v.object({ id: v.string(), name: v.string() }) }) .throws(err_user, ["not_found"]),
updateName: actionSchema() .input({ schema: v.object({ userId: v.string(), name: v.string() }) }) .output({ schema: v.object({ success: v.boolean() }) }), },});You can write the schemas with any Standard Schema library — Valibot, Zod, and so on.
Domain names are wire-global per runtime. Actions route by
domain+ action id alone — the parent chain is not part of the routing key — so every domain registered on one runtime must have a unique name, even across different subtrees. Registering a second, different domain object under an already-registered name fails at setup (domain_name_collision) rather than silently shadowing the first; re-registering the same object is always fine. This matters most when versioning an API: give the new domain a new name (act_bridge_v2), never a second definition of the old one.
Note: An input schema runs twice — once when the request is created, and again on the receiving runtime before your handler executes, so a hostile or version-skewed peer can never hand your handler unvalidated input. A schema must therefore accept its own validated output. Plain validators always do; avoid
transforms that change a value’s type — converting values is what the pack/unpack arguments below are for.
Sending values that aren’t plain JSON
Section titled “Sending values that aren’t plain JSON”Some values don’t survive a trip over the network on their own — a Date or a Map, for example. For those, pass a pair of functions (one to pack the value for sending, one to unpack it on arrival) as the 2nd and 3rd arguments:
createdAt: actionSchema() .output( { schema: v.object({ createdAt: v.date() }) }, ({ createdAt }) => ({ createdAt: createdAt.toISOString() }), // pack for sending ({ createdAt }) => ({ createdAt: new Date(createdAt) }), // unpack on arrival ),Your handler and your caller always see the real value (a Date here) — never the packed string that actually travels over the wire.
Note: The schema always describes your local, real value (the
Dateobject), not the packed wire form. The library runs standard schema validation after unpacking on the way in, and before packing on the way out — so validation only ever sees the real value, and your pack/unpack functions only ever deal with the wire form.
Saying which errors an action can throw
Section titled “Saying which errors an action can throw”Use .throws() to attach @nice-code/error domains. Whoever calls the action then gets those errors fully typed, so they can check for them by name.
import { defineNiceError, err } from "@nice-code/error";
const err_user = defineNiceError({ domain: "err_user", schema: { not_found: err<{ userId: string }>({ message: ({ userId }) => `User not found: ${userId}`, httpStatusCode: 404, context: { required: true }, }), },});
actionSchema() .throws(err_user) // can throw any error from err_user .throws(err_user, ["not_found"]); // can throw only "not_found"Streams that must not drop updates — .reliable()
Section titled “Streams that must not drop updates — .reliable()”By default an action is best-effort — perfect for request/response calls you’d just retry. For a stream
of updates that must all arrive, in order, without duplicates (game moves, a chat feed, incremental
sync), chain .reliable() onto the schema:
host_move: actionSchema() .input({ schema: v.object({ n: v.number() }) }) .reliable(), // ordered + deduped + resent across reconnectsThat one call is the whole opt-in — see Reliable Delivery for what it
guarantees, the persist option for surviving server restarts, and independent streams per room/entity.