Skip to content

Defining Actions

Root domains, child domains, action schemas, serialization, and thrown errors.

Start with a root domain — a namespace anchor with no actions — then hang child domains with actions off it. Both ends import these from shared code.

shared.ts
import { createActionRootDomain, actionSchema } from "@nice-code/action";
import * as v from "valibot";
export const appRoot = createActionRootDomain({ domain: "app_root" });
export const userDomain = appRoot.createChildDomain({
domain: "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() }) }),
},
});

Schemas accept any Standard Schema library — Valibot, Zod, etc.

When input or output contains values that aren’t JSON-native (a Date, a Map), pass a serialize/deserialize pair as the 2nd and 3rd arguments:

createAt: actionSchema()
.output(
{ schema: v.object({ createdAt: v.date() }) },
({ createdAt }) => ({ createdAt: createdAt.toISOString() }), // serialize
({ createdAt }) => ({ createdAt: new Date(createdAt) }), // deserialize
),

The handler always receives — and the caller always gets back — the real typed value, never the wire form.

Attach @nice-code/error domains with .throws(). These surface as typed, narrowable errors at the call site.

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 },
}),
},
} as const);
actionSchema()
.throws(err_user) // any id from err_user
.throws(err_user, ["not_found"]); // only specific ids

Next: Channels →