Guide

Query.option

Represent a genuinely uncertain state read as Option without weakening strict reads.

Query.option reads a state that belongs to the chart but may not be active at the query's perspective. It returns an Effect Option, preserving that uncertainty as data.

Optionality belongs on the uncertain read—not on every read around it. Keep facts that topology guarantees strict, and use Query.option only where presence can genuinely vary.

Make absence part of the type.

A query can span configurations without pretending every state is always active. Mark the uncertain read precisely, and its absence becomes part of the answer.

1 · Keep guarantees strict

A strict read defines the perspective.

This query promises that Collecting is active, so its selection is placed at Collecting. Once packing finishes, that perspective disappears and the whole selection becomes Absent.

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

const FinishPacking = Schema.TaggedStruct("FinishPacking", {});

const PackingListInput = Schema.Struct({
  orderId: Schema.String,
  warehouse: Schema.String,
  initialItems: Schema.Array(Schema.String),
});

const packingStatusQuery = Query.gen(function* () {
  const { orderId } = yield* PackingList;
  const { warehouse } = yield* Packing;
  const { items } = yield* Collecting;

  return `${orderId} · ${warehouse} · ${String(items.length)} items remaining`;
});

export class PackingList extends State.Compound<PackingList>()("PackingList", {
  orderId: Schema.String,
}) {
  static states = States.make(() => [Packing]);
}

export class Packing extends State.Compound<Packing>()("Packing", {
  warehouse: Schema.String,
}) {
  static states = States.make(() => [Collecting, Ready]);
}

export class Collecting extends State.Atomic<Collecting>()("Collecting", {
  items: Schema.Array(Schema.String),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FinishPacking, Ready),
  ]);
}

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

export const PackingListChart = Statechart.make(PackingList, {
  input: PackingListInput,
  init: ({ input }) => [
    new PackingList({ orderId: input.orderId }),
    new Packing({ warehouse: input.warehouse }),
    new Collecting({ items: input.initialItems }),
  ],
});

export const packingStatus = PackingListChart.at(Collecting).select(packingStatusQuery);

2 · Name real uncertainty

Make only the uncertain read optional.

From the stable Packing ancestor, PackingList and Packing remain strict. Query.option(Collecting) admits that the leaf may be inactive and returns an Option, so the selection survives to report ready.

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

const FinishPacking = Schema.TaggedStruct("FinishPacking", {});

const PackingListInput = Schema.Struct({
  orderId: Schema.String,
  warehouse: Schema.String,
  initialItems: Schema.Array(Schema.String),
});

const packingStatusQuery = Query.gen(function* () {
  const { orderId } = yield* PackingList;
  const { warehouse } = yield* Packing;
  const collecting = yield* Query.option(Collecting);

  return Option.match(collecting, {
    onNone: () => `${orderId} · ${warehouse} · ready`,
    onSome: ({ items }) => `${orderId} · ${warehouse} · ${String(items.length)} items remaining`,
  });
});

export class PackingList extends State.Compound<PackingList>()("PackingList", {
  orderId: Schema.String,
}) {
  static states = States.make(() => [Packing]);
}

export class Packing extends State.Compound<Packing>()("Packing", {
  warehouse: Schema.String,
}) {
  static states = States.make(() => [Collecting, Ready]);
}

export class Collecting extends State.Atomic<Collecting>()("Collecting", {
  items: Schema.Array(Schema.String),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(FinishPacking, Ready),
  ]);
}

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

export const PackingListChart = Statechart.make(PackingList, {
  input: PackingListInput,
  init: ({ input }) => [
    new PackingList({ orderId: input.orderId }),
    new Packing({ warehouse: input.warehouse }),
    new Collecting({ items: input.initialItems }),
  ],
});

export const packingStatus = PackingListChart.at(Packing).select(packingStatusQuery);

A strict read is a promise

The first frame reads PackingList, Packing, and Collecting strictly. Its perspective is Collecting, where the complete active path proves all three reads. The query can work directly with each facet because none may be absent there.

That promise also determines the selection's lifetime. FinishPacking exits Collecting, so the selection becomes Absent. Moving the same strict read to Packing would not make it more durable; Motive rejects it because Collecting is not guaranteed across every Packing configuration.

Optionality is local to one read

The second frame places the selection at Packing, the stable ancestor that remains active in both phases. Packing and its PackingList ancestor are still strict reads. Only Collecting becomes yield* Query.option(Collecting).

The result is Option.some while Collecting is active and Option.none after the transition to Ready. The query handles both cases with Option.match, so its returned status remains present for the whole Packing lifetime.

An optional read is not an optional selection

These are separate forms of absence. In the first frame, the selection itself is absent because its perspective is inactive. In the second, the Packing selection remains present while one read inside it evaluates to Option.none.

That distinction lets callers know whether there is no answer at this perspective, or whether the answer explicitly describes an inactive part of the chart. Query.option does not blur the two.

Preserve uncertainty as Option

Option prevents absence from collapsing into undefined, a sentinel string, or a guessed default facet. Pattern matching makes both cases visible and keeps the state-owned data available only in the some branch.

The optional state must still belong to the assembled chart. Query.option relaxes the active-at- this-perspective proof; it is not an escape hatch for foreign states or a substitute for choosing the correct topology.

Use a strict read whenever presence is an invariant. If a strict read fails, first ask whether the query has the wrong perspective or the model has the wrong ownership. Use Query.option when absence itself is part of the answer.

The next page introduces Query.fn: reusable queries whose arguments can shape the synchronous calculation without hiding its read requirements.

Test both cases

  1. Change the initial child of Packing to Ready and verify that the root selection starts with Option.none for Collecting.
  2. Replace Query.option(Collecting) with a strict read and inspect the topology error at the root perspective.
  3. Add a strict Packing field to the result and verify that only Collecting remains optional.