Defining Actions
Group actions into domains, give each one input and output schemas, and declare the errors it can throw.
Root and child domains
Section titled “Root and child domains”First make a root domain, which is an empty top-level namespace with no actions of its own. Then add child domains to it, and put your actions there. 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";import { err_user } from "./errors"; // defined further down this page
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, such as Valibot or Zod.
Every domain name must be unique on a runtime. A call finds its destination by
domainplus action id alone. The parent chain is not part of that routing key, so the rule holds even across subtrees. Registering a second, different domain object under a name that’s already taken fails at setup (domain_name_collision) instead of silently shadowing the first. Re-registering the same object is always fine. This matters most when you version 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 runs. 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, like a Date or a Map, don’t survive a trip over the network on their own. For those, pass two functions as the 2nd and 3rd arguments: one to pack the value for sending, and one to unpack it on arrival.
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. They never see the packed string on the wire.
Note: The schema always describes your local, real value (the
Dateobject), not the packed wire form. The library validates 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. Callers then get those errors fully typed and can check for them by name.
import { defineNiceError, err } from "@nice-code/error";
export 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, which suits request/response calls you would retry anyway. Some
streams of updates must all arrive, in order and without duplicates: game moves, a chat feed, incremental
sync. For those, chain .reliable() onto the schema:
host_move: actionSchema() .input({ schema: v.object({ n: v.number() }) }) .reliable(), // ordered + deduped + resent across reconnectsThat one call opts you in. Reliable Delivery covers what it
guarantees, the persist option for surviving server restarts, and independent streams per room or entity.