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
- Change the initial child of
PackingtoReadyand verify that the root selection starts withOption.noneforCollecting. - Replace
Query.option(Collecting)with a strict read and inspect the topology error at the root perspective. - Add a strict
Packingfield to the result and verify that onlyCollectingremains optional.