Guide

match

Express one ordered, total transition decision with guarded branches and a fallback.

A guard can leave a valid event with no enabled edge. When the event must choose exactly one outcome, match collects an ordered decision and makes its fallback explicit.

One event can choose among branches.

A guarded edge can refuse an event. match expresses the cases when the same accepted event must choose exactly one path.

1 · One guarded possibility

A guard may leave the event refused.

The familiar guarded edge accounts only for the matching code. EnterCode with any other string is valid, but no transition is enabled, so the send is Refused and Locked remains active.

import { Query, State, Statechart, States, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";

const EnterCode = Schema.TaggedStruct("EnterCode", {
  code: Schema.String,
});

const codeMatches = Query.gen(function* () {
  const event = yield* Query.event(EnterCode);
  const keypad = yield* Keypad;
  return event.code === keypad.accessCode;
});

class Keypad extends State.Compound<Keypad>()("Keypad", {
  accessCode: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("1234")),
  ),
}) {
  static states = States.make(() => [Locked, Unlocked]);
}

class Locked extends State.Atomic<Locked>()("Locked") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(EnterCode, Unlocked).when(codeMatches),
  ]);
}

class Unlocked extends State.Atomic<Unlocked>()("Unlocked") {}

export const KeypadChart = Statechart.make(Keypad);

2 · One total decision

match gives every valid event one branch.

The guarded branch is considered first. If codeMatches is true, the chart enters Unlocked; otherwise orElse handles the same accepted event by entering Denied.

import { Query, State, Statechart, States, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";

const EnterCode = Schema.TaggedStruct("EnterCode", {
  code: Schema.String,
});

const codeMatches = Query.gen(function* () {
  const event = yield* Query.event(EnterCode);
  const keypad = yield* Keypad;
  return event.code === keypad.accessCode;
});

class Keypad extends State.Compound<Keypad>()("Keypad", {
  accessCode: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("1234")),
  ),
}) {
  static states = States.make(() => [Locked, Unlocked, Denied]);
}

class Locked extends State.Atomic<Locked>()("Locked") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(EnterCode, ({ match }) =>
      match
        .when(codeMatches, (then) => then(Unlocked))
        .orElse((then) => then(Denied)),
    ),
  ]);
}

class Unlocked extends State.Atomic<Unlocked>()("Unlocked") {}

class Denied extends State.Atomic<Denied>()("Denied") {}

export const KeypadChart = Statechart.make(Keypad);

One guard answers one possibility

The first frame is the final chart from Guards. codeMatches enables one transition to Unlocked. If the admitted code does not match, that transition is disabled and no other edge handles EnterCode, so the send is Refused.

That is often the right policy. But some domains need to account for every admitted event rather than leave the non-matching case unhandled.

match groups one transition decision

The second frame changes the value passed to on:

on(EnterCode, ({ match }) =>
  match.when(codeMatches, (then) => then(Unlocked)).orElse((then) => then(Denied)),
);

The callback receives a staged matcher for this exact EnterCode edge. .when contributes a guarded branch, and .orElse contributes the fallback that closes the ordered decision. Each branch callback receives then, which can stay put or target another state.

These are not separate event handlers. Together they are one authored disposition for one event at one active source state.

Order is behavior

match considers branches from left to right and selects the first eligible branch. Here there is only one guard, so a matching access code enters Unlocked and every other code reaches the fallback.

With several when branches, put the most specific decision first. If two guards are true, the earlier branch owns the event; later branches are not also applied.

orElse closes the decision

The fallback is deliberately visible. It says what the chart does when no preceding guard is enabled, and it makes this decision total for admitted EnterCode values.

Submit 0000 in the second frame. The result is Handled, not Refused, because orElse takes the event to Denied. Submit 1234 and the earlier guarded branch takes it to Unlocked.

The live workbench starts a fresh Keypad instance for each submission so you can compare both paths without adding a reset event to the model.

Branches are still transitions

Each branch chooses its own target and may use the same transition refinements available at that site. match changes how the chart selects one branch; it does not create a second execution phase or run every eligible branch.

Use a single transition .when(...) when refusal is meaningful. Use the staged match form when one event needs an ordered choice with an explicit fallback.

Test the decision

  1. Add a maintenance-code when branch before codeMatches and give it a third target.
  2. Reverse two overlapping guarded branches and observe which target wins.
  3. Remove orElse and read the totality diagnostic produced by chart assembly.