Commiq Docs
Usage Patterns

Command Validation

Validate command data with guards for business rules and assertions for developer invariants.

Commands carry data from the outside world — user input, API responses, URL parameters. Validating it before acting prevents invalid state transitions and produces clear messages. commiq-context provides two complementary tools: withGuard for business rules and withAssert for developer invariants.

Guards for business rules

withGuard() adds ctx.guard(condition, message) to command contexts. When the condition is false it throws a GuardError, which stops the handler. Nothing below the failing guard runs, so the store is never left half-updated.

stores/cart.ts
import { createStore, createCommandDef, createEvent, sealStore } from "@naikidev/commiq";
import { withGuard, withPatch } from "@naikidev/commiq-context";

type CartItem = { readonly id: string; readonly name: string; readonly quantity: number };

type CartState = {
  readonly items: readonly CartItem[];
  readonly maxItems: number;
};

export const CartCommand = {
  addItem: createCommandDef<{ id: string; name: string }>("cart:addItem"),
  setQuantity: createCommandDef<{ id: string; quantity: number }>("cart:setQuantity"),
};

export const CartEvent = {
  ItemAdded: createEvent<{ id: string }>("cart:itemAdded"),
};

const MAX_QUANTITY = 99;

const _store = createStore<CartState>({ items: [], maxItems: 20 })
  .useExtension(withGuard<CartState>())
  .useExtension(withPatch<CartState>())
  .addCommandHandler(CartCommand.addItem, (ctx, cmd) => {
    ctx.guard(
      ctx.state.items.length < ctx.state.maxItems,
      `Cart is full (max ${ctx.state.maxItems} items)`,
    );
    ctx.guard(
      !ctx.state.items.some((item) => item.id === cmd.data.id),
      `"${cmd.data.name}" is already in the cart`,
    );

    ctx.patch({ items: [...ctx.state.items, { ...cmd.data, quantity: 1 }] });
    ctx.emit(CartEvent.ItemAdded, { id: cmd.data.id });
  })
  .addCommandHandler(CartCommand.setQuantity, (ctx, cmd) => {
    const { id, quantity } = cmd.data;

    ctx.guard(quantity > 0, "Quantity must be positive");
    ctx.guard(quantity <= MAX_QUANTITY, `Quantity cannot exceed ${MAX_QUANTITY}`);

    const item = ctx.state.items.find((candidate) => candidate.id === id);
    ctx.guard(item !== undefined, `Item "${id}" is not in the cart`);

    ctx.patch({
      items: ctx.state.items.map((candidate) =>
        candidate.id === id ? { ...candidate, quantity } : candidate,
      ),
    });
  });

export const cartStore = sealStore(_store);

Guards read like preconditions. withPatch() adds ctx.patch(partial), a shallow merge over the current state — it is equivalent to ctx.setState((prev) => ({ ...prev, ...partial })).

ctx.guard is typed (condition: boolean, message: string) => void, not a TypeScript assertion signature. It does not narrow types. After ctx.guard(item !== undefined, ...), item is still CartItem | undefined as far as the compiler is concerned. Use it for runtime validation; use a normal early return or a non-null check when you need narrowing.

Both withGuard and withPatch are command-only extensions — they add nothing to event contexts. Referencing ctx.guard from an event handler is a compile error, because core tracks command and event context types separately.

Observing guard failures

A GuardError escaping a handler is an ordinary command failure. It goes to three places:

  • StoreOptions.onError with source: "commandHandler"
  • the commandHandlingError builtin event
  • the CommandHandle returned by queue(), as { status: "failed", error }

Because guard messages are written for users, the handle is often the most direct path — you get the message at the dispatch site:

components/AddToCartButton.tsx
import { GuardError } from "@naikidev/commiq-context";
import { useQueue } from "@naikidev/commiq-react";

function AddToCartButton({ product }: { product: Product }) {
  const queue = useQueue(cartStore);

  const handleClick = async () => {
    const result = await queue(CartCommand.addItem, {
      id: product.id,
      name: product.name,
    });

    if (result.status === "failed" && result.error instanceof GuardError) {
      showToast(result.error.message, { type: "error" });
    }
  };

  return <button onClick={handleClick}>Add to cart</button>;
}

GuardError and AssertionError are both exported and both extend ContextCheckError, so instanceof distinguishes a validation failure from an unexpected crash. GuardError.message is your message verbatim; AssertionError.message is prefixed with "Assertion failed: ".

The stream-based form still works if the reaction is global rather than tied to one dispatch:

stores/cart-errors.ts
import { BuiltinEvent, matchEvent } from "@naikidev/commiq";
import { ContextCheckError } from "@naikidev/commiq-context";

const unsubscribe = cartStore.openStream((event) => {
  if (!matchEvent(event, BuiltinEvent.CommandHandlingError)) return;
  if (!(event.data.error instanceof ContextCheckError)) return;

  showToast(event.data.error.message, { type: "error" });
});

openStream returns an Unsubscribe; keep it if the listener should not outlive the module. Filtering on ContextCheckError matters here — without it this toast fires for every crash in every handler, including ones with messages no user should see.

Assertions for developer invariants

withAssert() adds ctx.assert(condition, message) to both command and event contexts. Assertions describe conditions that cannot be false in correct code: a missing initialization step, a broken state machine, a handler reached out of order.

stores/checkout.ts
import { withAssert, withGuard } from "@naikidev/commiq-context";

const _store = createStore<CheckoutState>(initialState)
  .useExtension(withGuard<CheckoutState>())
  .useExtension(withAssert<CheckoutState>({ enabled: import.meta.env.DEV }))
  .addCommandHandler(CheckoutCommand.submit, async (ctx) => {
    ctx.assert(ctx.state.step === "review", "checkout must be in the review step");
    ctx.assert(ctx.state.paymentMethod !== null, "payment method must be set");

    ctx.guard(ctx.state.items.length > 0, "Your cart is empty");
    ctx.guard(ctx.state.total > 0, "Cart total must be positive");

    ctx.setState((prev) => ({ ...prev, step: "processing" }));
    // ... submit the order
  });
  • Assertions check internal invariants. step and paymentMethod should already be set by earlier steps; if they are not, the bug is in the code.
  • Guards check business rules. An empty cart is a legitimate user state.

With { enabled: false } the check function returns immediately — the condition is still evaluated by the caller, so an expensive expression inside an assertion still costs something. Keep assertion conditions cheap. Guards have no enabled default of false; both extensions default to enabled: true.

Guard vs. assert

withGuardwithAssert
PurposeBusiness rule validationDeveloper invariant checking
Failure meansInvalid user action or dataA bug in the code
User-facingYes — show the messageNo — report it
ContextsCommand onlyCommand and event
ProductionKeep enabledUsually disabled
Error classGuardErrorAssertionError
MessageUser-friendly: "Your cart is empty"Developer-friendly: "items must be initialized"
PrefixNone"Assertion failed: "

Both produce a failed command, so neither is a way to silently reject a command. If a command should legitimately do nothing under some condition, return early instead — a no-op that settles as handled is not the same signal as a failure.

Validating shapes at the boundary

Guards check business rules against data whose shape is already known. When the data comes from a transport or JSON.parse, validate the shape before it becomes a command:

transport/orders.ts
import { orderStore, OrderCommand } from "../stores/order";

sse.addEventListener("order", (event) => {
  const parsed = orderSchema.safeParse(JSON.parse(event.data));

  if (!parsed.success) {
    orderStore.queue(OrderCommand.recordMalformed, { raw: event.data });
    return;
  }

  orderStore.queue(OrderCommand.received, parsed.data);
});

A createCommandDef<D> guarantees the static type of cmd.data, which is a compile-time claim about your own code — it does not validate anything at runtime. Data crossing a network or storage boundary needs a real schema check first. See real-time transports.

Testing validation

Assert on the handle's result rather than on state, so a passing test cannot be one that simply never ran the command:

cart.test.ts
import { expect, test, vi } from "vitest";
import { GuardError } from "@naikidev/commiq-context";

test("adding a duplicate item fails with a guard error", async () => {
  const onError = vi.fn();
  const store = createCartStore({ onError });

  await store.queue(CartCommand.addItem, { id: "a", name: "Espresso" });
  const result = await store.queue(CartCommand.addItem, { id: "a", name: "Espresso" });

  expect(result.status).toBe("failed");
  expect(result.error).toBeInstanceOf(GuardError);
  expect((result.error as GuardError).message).toContain("already in the cart");
  expect(store.state.items).toHaveLength(1);
});

Passing an onError spy keeps the expected failure out of the test output — the default reporter would otherwise log it. See testing.

On this page