Commiq Docs
Usage Patterns

Typed Command Definitions

Use createCommandDef so a command's name and payload type travel together, and know when a hand-rolled factory is still better.

Typed Command Definitions

A command name written as a string literal at every dispatch site is fragile: a typo produces an invalidCommand at runtime rather than a compile error, and nothing checks the payload shape against the handler that will receive it.

createCommandDef<D>(name) fixes both. It creates a single object that carries the name and the payload type, and that object is used in two places: registering the handler and dispatching the command. Those two are then checked against each other.

The problem

// Nothing ties these three together
_store.addCommandHandler<{ amount: number }>("counter:increment", handler);

queue(createCommand("counter:increment", { amunt: 1 }));   // typo in the key
queue(createCommand("counter:incriment", { amount: 1 }));  // typo in the name

Both dispatches compile. The first passes { amunt: 1 } to a handler that reads cmd.data.amount and gets undefined. The second reaches no handler at all — it emits invalidCommand and its handle settles as invalid, which you only see if you are looking.

The pattern

Declare the definitions in one place:

stores/counter/commands.ts
import { createCommandDef } from "@naikidev/commiq";

export const CounterCommand = {
  increment: createCommandDef("counter:increment"),
  decrement: createCommandDef("counter:decrement"),
  incrementBy: createCommandDef<{ amount: number }>("counter:incrementBy"),
  reset: createCommandDef("counter:reset"),
};

Note these are values, not functions — createCommandDef is called once at module load, not per dispatch.

Register handlers with the definition. The handler's cmd.data is inferred, with no type argument on addCommandHandler:

stores/counter/store.ts
import { createStore, sealStore } from "@naikidev/commiq";
import { CounterCommand } from "./commands";
import { CounterEvent } from "./events";

type CounterState = { readonly count: number };

const initialState: CounterState = { count: 0 };

const _store = createStore<CounterState>(initialState);

_store
  .addCommandHandler(CounterCommand.increment, (ctx) => {
    ctx.setState((prev) => ({ count: prev.count + 1 }));
  })
  .addCommandHandler(CounterCommand.incrementBy, (ctx, cmd) => {
    // cmd.data is { amount: number } — inferred from the definition
    ctx.setState((prev) => ({ count: prev.count + cmd.data.amount }));
  })
  .addCommandHandler(CounterCommand.reset, (ctx) => {
    ctx.setState(initialState);
    ctx.emit(CounterEvent.Reset, undefined);
  });

export const counterStore = sealStore(_store);

Dispatch with the definition and its payload as a second argument:

import { CounterCommand } from "./stores/counter";

const queue = useQueue(counterStore);

queue(CounterCommand.increment);                 // no payload
queue(CounterCommand.incrementBy, { amount: 5 }); // payload required

A definition created with no payload type takes no second argument. One created with a payload requires it. Both are compile errors the other way round:

queue(CounterCommand.increment, { amount: 5 });  // error: expected 1 argument
queue(CounterCommand.incrementBy);               // error: expected 2 arguments
queue(CounterCommand.incrementBy, { amunt: 5 }); // error: unknown property

removeCommandHandler(def) and useCommandStatus(store, def) accept the same definition, so the name stays in exactly one place.

What this does and does not buy you

It does catch payload-shape mismatches and give you autocomplete on CounterCommand..

It does not make dispatching a wrong-but-valid command impossible: queue still has an overload accepting a raw Command, because commands arriving from a transport or a serialized log have to be dispatchable. Nor does it check that a handler exists for the definition — registering the handler is a separate statement, and a definition with no handler still produces invalidCommand at runtime.

A payload type of void and a payload type of undefined behave the same at the call site. createCommandDef("x") defaults to void.

When a hand-rolled factory is still better

createCommandDef binds a name to a payload type. A factory function builds the payload. Use one when there is real work between the caller's arguments and the command's data:

stores/cart/commands.ts
import { createCommand, createCommandDef } from "@naikidev/commiq";

// Definition: used to register the handler and to read status
export const CartCommand = {
  addItem: createCommandDef<{ productId: string; quantity: number }>("cart:addItem"),
};

// Factory: the call site should not have to spell out quantity: 1
export const addOne = (productId: string) =>
  createCommand("cart:addItem", { productId, quantity: 1 });

Cases where a factory earns its keep:

  • A default the caller should not repeatquantity: 1, attempt: 1, source: "ui"
  • Positional arguments that read better than an objectupdateQty(productId, qty) rather than queue(def, { productId, qty })
  • Normalization at the boundary — trimming a query string, coercing a numeric input, generating a client-side id

The cost is that the factory's return type is a plain Command, so the link back to the handler's registration is broken — createCommand("cart:addItem", ...) repeats the name as a string and nothing checks it against the definition. Keep the definition as the single source of the name and have the factory reference it:

export const addOne = (productId: string) =>
  createCommand(CartCommand.addItem.name, { productId, quantity: 1 });

That is a fair trade for two or three commands with awkward call sites. For the rest, use the definition directly.

Where to place definitions

Definitions are part of a store's public API. In a single-file store, keep them next to the events and the sealed export. In a domain folder, put them in commands.ts and re-export from index.ts:

stores/counter/index.ts
export { CounterCommand } from "./commands";
export { CounterEvent } from "./events";
export { counterStore } from "./store";
export type { CounterState } from "./store";

Components should generally not import command definitions at all — a domain hook is a better boundary. useCounter() returning { count, increment, incrementBy } keeps the component from knowing that commands exist.

Namespacing

Prefix every name with its domain — "counter:increment", not "increment". Command handlers are keyed by name on each store, so two stores can each register "reset" without conflicting, but the names appear in devtools, in error reports, and in useCommandStatus, and an unqualified "reset" is unreadable in all three. Registering the same name twice on one store replaces the handler and reports source: "duplicateHandler" through onError.

On this page