Skip to content
5 packagesOne type-safe spine.

Typed actions.
Typed errors.
Reactive state.

Transport-agnostic, bi-directional actions with full TypeScript inference — call functions across the network, a worker, or a Durable Object as if they were local. Backed by typed errors, Immer state, and the crypto + storage primitives that tie it all together.

$bun add @nice-code/action @nice-code/error valibot
app.ts ts
// @nice-code/error — a typed error domain
const err_user = defineNiceError({
  domain: "err_user",
  schema: { not_found: err<{ userId: string }>({ httpStatusCode: 404 }) },
});

// @nice-code/action — input, output & the errors it throws
const getUser = actionSchema()
  .input({ schema: v.object({ userId: v.string() }) })
  .output({ schema: v.object({ id: v.string() }) })
  .throws(err_user, ["not_found"]);

// Call it anywhere — local, WebSocket, HTTP — fully typed
const user = await userDomain.action.getUser
  .request({ userId: "u_1" }).runToOutput();

// @nice-code/state — drop it into a reactive store
userStore.update((s) => { s.user = user; });
Typed actions
Call across the network, a worker, or a Durable Object — full inference.
Errors as data
Declare error schemas; typed context flows from throw to catch.
Reactive state
Immer-backed store, fine-grained selectors, tear-free React.
Edge-native
WebSocket, HTTP, Workers, Durable Objects — built for the edge.
The packages

Five small libraries,
one ergonomic surface.

@nice-code/action is the headline — typed calls that travel across any boundary. The rest compose around it: errors declared with defineNiceError plug straight into action .throws() clauses; secure channels use the crypto link from util; validation failures surface as common-errors. Each package still stands on its own.

@nice-code/action

Actions that travel

Transport-agnostic, bi-directional calls with full inference. Local, over a WebSocket, over HTTP, or peer-to-peer.

  • Channels declared once, by role
  • Bi-directional push over one socket
  • Deterministic results: expected vs unhandled
  • WebSocket · HTTP · Durable Objects
Read the guide
@nice-code/error

Errors as data

Declare your error schema up front. Every id, every context field, every status — typed and reachable from any catch.

  • Typed context: optional / required
  • Multi-id errors in one object
  • Parent/child domain hierarchies
  • castNiceError · handleWith · matchFirst
Read the guide
@nice-code/state

Reactive state

Immer-backed store with fine-grained selectors, derived reactions, and patch streams. Tear-free React adapter.

  • Mutate via Immer drafts
  • Selector subscriptions, no provider
  • Reactions derive state from state
  • Patches + devtools timeline
Read the guide
@nice-code/common-errors

Validation, shared

A Standard Schema validation error domain plus Hono middleware that throws a NiceError instead of a bare 400.

  • err_validation domain
  • niceSValidator drop-in
  • niceCatchSValidation interceptor
  • Works with Valibot, Zod, …
Read the guide
@nice-code/util

Storage + crypto

Typed storage adapters (browser, Durable Objects, memory) and WebCrypto helpers — Ed25519, X25519, AES-GCM.

  • ITypedStorage over any backend
  • Ed25519 sign / verify
  • X25519 + AES-GCM shared keys
  • ClientCryptoKeyLink
Read the guide
End to end

Define once in shared code —
call it from either side.

server.ts ts
// Implement the action, then serve the channel
const handler = userDomain.wrapAsLocalHandler({
  getUser: async ({ userId }) => db.users.find(userId),
});

serveChannel(runtime, appChannel, {
  storage, handlers: [handler],
  carriers: [wsAcceptorCarrier({ /* host */ }), httpAcceptorCarrier()],
});
client.ts ts
// Connect once over carriers (WS preferred, HTTP fallback)
connectChannel(clientRuntime, appChannel, {
  peer: serverCoord, storage,
  transports: [{ carrier: wsCarrier(wsUrl) }],
});

// ...then call from anywhere — output fully typed
const user = await userDomain.action.getUser
  .request({ userId: "u_1" }).runToOutput();