Skip to content

Quick start

Install nice-code, define a typed error domain, and wire up your first action.

Terminal window
# errors only
bun add @nice-code/error
# actions (build on errors) + a Standard Schema library
bun add @nice-code/action @nice-code/error valibot
# state
bun add @nice-code/state immer

@nice-code/action is the centre of nice-code — start here. A typical app is three files: shared.ts (imported by both ends), server.ts (the acceptor), client.ts (the connector). Actions declare the typed errors they throw from an error domain — err_user below comes from the error domain in the next section.

shared.ts
import { createActionRootDomain, actionSchema, defineChannel } from "@nice-code/action";
import * as v from "valibot";
import { err_user } from "./errors";
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"]),
},
});
export const appChannel = defineChannel({
toAcceptor: [userDomain], // client → server
toConnector: [], // server → client pushes (none here)
});
server.ts
import { ActionRuntime, RuntimeCoordinate, serveChannel, wsAcceptorCarrier, httpAcceptorCarrier } from "@nice-code/action";
import { appChannel, userDomain } from "./shared";
export const serverCoord = RuntimeCoordinate.env("backend");
const runtime = new ActionRuntime(serverCoord);
const userHandler = userDomain.wrapAsLocalHandler({
getUser: async ({ userId }) => {
const user = await db.users.find(userId);
if (!user) throw err_user.fromId("not_found", { userId });
return user;
},
});
const server = serveChannel(runtime, appChannel, {
clientEnv: RuntimeCoordinate.env("frontend"),
storage: storageAdapter,
handlers: [userHandler],
carriers: [wsAcceptorCarrier({ /* host send/upgrade */ }), httpAcceptorCarrier()],
});

3. Client — connect once, call from anywhere

Section titled “3. Client — connect once, call from anywhere”
client.ts
import { ActionRuntime, RuntimeCoordinate, connectChannel, wsCarrier, httpCarrier } from "@nice-code/action";
import { appChannel, userDomain } from "./shared";
import { serverCoord } from "./server";
const clientRuntime = new ActionRuntime(RuntimeCoordinate.env("frontend"));
connectChannel(clientRuntime, appChannel, {
peer: serverCoord,
storage,
transports: [
{ carrier: wsCarrier(() => ({ url: "wss://api.example.com/ws" })) },
{ carrier: httpCarrier(() => ({ url: "https://api.example.com/action" })), secure: false },
],
});
const user = await userDomain.action.getUser
.request({ userId: "u_1" })
.runToOutput();

That’s the whole loop. For the result-based outcome (branch on expected vs isUnhandled instead of a try/catch), see Error Handling →.

The error domain the action above throws from. Declare the schema once; every id, context field, and HTTP status is then typed at the throw site and from any catch.

errors.ts
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 },
}),
account_locked: err({
message: "Account is locked",
httpStatusCode: 403,
}),
},
} as const);
import { castNiceError } from "@nice-code/error";
throw err_user.fromId("not_found", { userId: "u_1" });
// On the receiving side — castNiceError always returns a NiceError
const caught = castNiceError(e);
if (err_user.isExact(caught) && caught.hasId("not_found")) {
const { userId } = caught.getContext("not_found"); // typed
}

Continue into the mental model → to understand runtimes, channels, and carriers.