Skip to content

Commit e02dba3

Browse files
Allow active states to omit schemas (#105)
* Add schema-less active states * Adopt schema-less states in examples
1 parent 192899f commit e02dba3

32 files changed

Lines changed: 1638 additions & 632 deletions

File tree

.changeset/quiet-states.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Allow active states to omit `schema` when they own no data. Schema-less atomic, compound, parallel, and final states keep full control-flow semantics while exposing value-free `.from(...)` builders, `undefined` handler state, and snapshot-only query APIs.
6+
7+
```ts
8+
const States = Machine.defineStates({
9+
Form: {
10+
initial: "Editing",
11+
states: { Editing: {}, Saving }
12+
}
13+
})
14+
15+
States.initial.Form.from((form) => form.Editing.from())
16+
```

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,27 @@ defaults, refinements, and tagged-class identity are therefore preserved, and
9494
decode failures remain typed machine failures. Pass a value directly only when
9595
it is already decoded, such as a value returned by `Machine.retag`.
9696

97+
Omit `schema` when a state represents control flow but owns no data:
98+
99+
```ts
100+
const States = Machine.defineStates({
101+
Form: {
102+
initial: "Editing",
103+
states: {
104+
Editing: {},
105+
Saving
106+
}
107+
}
108+
})
109+
110+
States.initial.Form.from((form) => form.Editing.from())
111+
```
112+
113+
Schema-less states remain active, targetable, matchable, and visible through
114+
`getSnapshot`, but have no value to read. Their builders expose only `.from`,
115+
their handler `state` is `undefined`, and `get` / `getWithParents` accept only
116+
schema-backed paths. Add a schema later if the state starts owning data.
117+
97118
Put data on the narrowest state where it is valid. If sibling phases share
98119
data, put it on their compound parent.
99120

docs/agent-guide.md

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Use this order so inference has all schemas available when handlers are
2121
declared:
2222

2323
1. Domain schemas used by state and event fields.
24-
2. Tagged state schemas.
24+
2. Tagged schemas for states that own data.
2525
3. Tagged public-event, internal-event, and emitted-event schemas.
2626
4. `Machine.defineStates`.
2727
5. `Machine.make`, including input, events, internal events, emits, and the
@@ -108,24 +108,63 @@ its extra control is required:
108108

109109
## Atomic, compound, parallel, and history states
110110

111+
An active state does not need a schema unless it owns data. Omit `schema` for
112+
control-only atomic, compound, parallel, and final states:
113+
114+
```ts
115+
const States = Machine.defineStates({
116+
Idle: {},
117+
Form: {
118+
initial: "Editing",
119+
states: {
120+
Editing: {},
121+
Saving: State.cases.Saving
122+
}
123+
}
124+
})
125+
126+
States.initial.Idle.from()
127+
States.initial.Form.from((form) => form.Editing.from())
128+
```
129+
130+
Schema-less states have the same control semantics as schema-backed states:
131+
they are active, targetable, matchable, receive lifecycle handlers, and appear
132+
in snapshots. They do not have a state value:
133+
134+
```ts
135+
Idle: {
136+
on: {
137+
Start: ({ state, target }) => {
138+
// state: undefined
139+
return target.full.Form.from((form) => form.Editing.from())
140+
}
141+
}
142+
}
143+
144+
States.matches(snapshot, "Form") // allowed
145+
States.getSnapshot(snapshot, "Form") // allowed
146+
States.get(snapshot, "Form") // type error: no value schema
147+
```
148+
149+
For a schema-less path, builders expose only `.from(...)`; the direct callable
150+
form is reserved for already-decoded schema values. Structural ancestors are
151+
also omitted from `parents`; an immediate structural parent is typed as
152+
`undefined`. Add `schema` when a state begins to own data or needs runtime
153+
validation and persistence for that data.
154+
111155
Use an atomic state when no child phase can be active beneath it.
112156

113157
Use a compound state when exactly one child phase is active. It must declare an
114158
`initial` child:
115159

116160
```ts
117-
const FormState = Schema.TaggedUnion({
118-
Form: { draft: Schema.String },
119-
Editing: {},
120-
Saving: {}
121-
})
161+
const FormState = Schema.TaggedUnion({ Saving: { draft: Schema.String } })
122162

123163
const FormStates = Machine.defineStates({
124164
Form: {
125-
schema: FormState.cases.Form,
126165
initial: "Editing",
127166
states: {
128-
Editing: FormState.cases.Editing,
167+
Editing: {},
129168
Saving: FormState.cases.Saving
130169
}
131170
}
@@ -135,35 +174,22 @@ const FormStates = Machine.defineStates({
135174
Use a parallel state when every direct region is active:
136175

137176
```ts
138-
const ParallelState = Schema.TaggedUnion({
139-
Screen: {},
140-
Network: {},
141-
Online: {},
142-
Offline: {},
143-
Panel: {},
144-
Closed: {},
145-
Open: {}
146-
})
147-
148177
const ParallelStates = Machine.defineStates({
149178
Screen: {
150-
schema: ParallelState.cases.Screen,
151179
type: "parallel",
152180
states: {
153181
network: {
154-
schema: ParallelState.cases.Network,
155182
initial: "Online",
156183
states: {
157-
Online: ParallelState.cases.Online,
158-
Offline: ParallelState.cases.Offline
184+
Online: {},
185+
Offline: {}
159186
}
160187
},
161188
panel: {
162-
schema: ParallelState.cases.Panel,
163189
initial: "Closed",
164190
states: {
165-
Closed: ParallelState.cases.Closed,
166-
Open: ParallelState.cases.Open
191+
Closed: {},
192+
Open: {}
167193
}
168194
}
169195
}
@@ -366,9 +392,11 @@ const ready = Option.getOrThrow(States.getSnapshot(snapshot, "Route.Ready"))
366392
States.matches(ready, "Route.Ready.Saving")
367393
```
368394

369-
All paths are checked against the definition. `context.parent` is the immediate
370-
typed parent (`undefined` at a root). Use `parents` when another ancestor is
371-
needed:
395+
All paths are checked against the definition. `get` and `getWithParents` accept
396+
only schema-backed paths; use `matches` or `getSnapshot` for any active path.
397+
`context.parent` is the immediate typed parent value (`undefined` at a root or
398+
when that parent is schema-less). `parents` contains only valued ancestors. Use
399+
its full paths when another ancestor value is needed:
372400

373401
```ts
374402
parents["Route.Ready"]

examples/platformer/src/machine.ts

Lines changed: 38 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,14 @@ export type Axis = typeof Axis.Type
77
const JumpKind = Schema.Literals(["Ground", "Double", "Wall"])
88

99
const State = Schema.TaggedUnion({
10-
Character: {},
11-
Locomotion: {},
12-
Playing: {},
1310
Paused: { pausedAt: Schema.Number },
14-
Grounded: {},
15-
Standing: {},
1611
Running: { startedAt: Schema.Number },
1712
Ducking: { startedAt: Schema.Number },
1813
Landing: { impact: Schema.Number, resumeAxis: Axis, landedAt: Schema.Number },
1914
Airborne: { originY: Schema.Number },
20-
Motion: {},
2115
Jumping: { startedAt: Schema.Number, push: Axis, kind: JumpKind },
2216
Falling: { apexY: Schema.Number },
23-
Diving: { startedAt: Schema.Number },
24-
AirJump: {},
25-
AirJumpGroundLock: {},
26-
AirJumpWallLock: {},
27-
AirJumpReady: {},
28-
AirJumpSpent: {},
29-
WallContact: {},
30-
NoWall: {},
31-
LeftWall: {},
32-
RightWall: {},
33-
Facing: {},
34-
Left: {},
35-
Right: {}
17+
Diving: { startedAt: Schema.Number }
3618
})
3719

3820
// Inputs and physics facts are one runtime-decoded, statically typed protocol.
@@ -61,22 +43,18 @@ const awayFrom = (wall: Axis): Axis => (wall === -1 ? 1 : wall === 1 ? -1 : 0)
6143

6244
export const CharacterStates = Machine.defineStates({
6345
Character: {
64-
schema: State.cases.Character,
6546
type: "parallel",
6647
states: {
6748
locomotion: {
68-
schema: State.cases.Locomotion,
6949
initial: "Playing",
7050
states: {
7151
Playing: {
72-
schema: State.cases.Playing,
7352
initial: "Grounded",
7453
states: {
7554
Grounded: {
76-
schema: State.cases.Grounded,
7755
initial: "Standing",
7856
states: {
79-
Standing: State.cases.Standing,
57+
Standing: {},
8058
Running: State.cases.Running,
8159
Ducking: State.cases.Ducking,
8260
Landing: State.cases.Landing
@@ -87,7 +65,6 @@ export const CharacterStates = Machine.defineStates({
8765
type: "parallel",
8866
states: {
8967
motion: {
90-
schema: State.cases.Motion,
9168
initial: "Jumping",
9269
states: {
9370
Jumping: State.cases.Jumping,
@@ -96,13 +73,12 @@ export const CharacterStates = Machine.defineStates({
9673
}
9774
},
9875
airJump: {
99-
schema: State.cases.AirJump,
10076
initial: "AirJumpGroundLock",
10177
states: {
102-
AirJumpGroundLock: State.cases.AirJumpGroundLock,
103-
AirJumpWallLock: State.cases.AirJumpWallLock,
104-
AirJumpReady: State.cases.AirJumpReady,
105-
AirJumpSpent: State.cases.AirJumpSpent
78+
AirJumpGroundLock: {},
79+
AirJumpWallLock: {},
80+
AirJumpReady: {},
81+
AirJumpSpent: {}
10682
}
10783
}
10884
}
@@ -117,20 +93,18 @@ export const CharacterStates = Machine.defineStates({
11793
}
11894
},
11995
facing: {
120-
schema: State.cases.Facing,
12196
initial: "Right",
12297
states: {
123-
Left: State.cases.Left,
124-
Right: State.cases.Right
98+
Left: {},
99+
Right: {}
125100
}
126101
},
127102
contact: {
128-
schema: State.cases.WallContact,
129103
initial: "NoWall",
130104
states: {
131-
NoWall: State.cases.NoWall,
132-
LeftWall: State.cases.LeftWall,
133-
RightWall: State.cases.RightWall
105+
NoWall: {},
106+
LeftWall: {},
107+
RightWall: {}
134108
}
135109
}
136110
}
@@ -457,9 +431,12 @@ export const locomotionState = (snapshot: CharacterSnapshot) => {
457431
}
458432

459433
const playing = locomotion.state
460-
return playing.path === "Character.locomotion.Playing.Grounded"
461-
? playing.state.value
462-
: playing.states.motion.state.value
434+
if (playing.path === "Character.locomotion.Playing.Airborne") {
435+
return playing.states.motion.state.value
436+
}
437+
return playing.state.path === "Character.locomotion.Playing.Grounded.Standing"
438+
? { _tag: "Standing" as const }
439+
: playing.state.value
463440
}
464441

465442
export type LocomotionMode = ReturnType<typeof locomotionState>["_tag"]
@@ -468,22 +445,32 @@ export const locomotionMode = (snapshot: CharacterSnapshot): LocomotionMode => l
468445

469446
export const locomotionBranch = (snapshot: CharacterSnapshot) => {
470447
const locomotion = snapshot.states.locomotion.state
471-
return locomotion.path === "Character.locomotion.Paused" ? locomotion.value._tag : locomotion.state.value._tag
448+
if (locomotion.path === "Character.locomotion.Paused") return "Paused" as const
449+
return locomotion.state.path === "Character.locomotion.Playing.Grounded" ? "Grounded" as const : "Airborne" as const
472450
}
473451

474452
export const airJumpMode = (snapshot: CharacterSnapshot) => {
475453
const locomotion = snapshot.states.locomotion.state
476-
return locomotion.path === "Character.locomotion.Playing" &&
477-
locomotion.state.path === "Character.locomotion.Playing.Airborne"
478-
? locomotion.state.states.airJump.state.value._tag
479-
: undefined
454+
if (
455+
locomotion.path !== "Character.locomotion.Playing" ||
456+
locomotion.state.path !== "Character.locomotion.Playing.Airborne"
457+
) return undefined
458+
459+
const path = locomotion.state.states.airJump.state.path
460+
if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpGroundLock") return "AirJumpGroundLock" as const
461+
if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpWallLock") return "AirJumpWallLock" as const
462+
if (path === "Character.locomotion.Playing.Airborne.airJump.AirJumpReady") return "AirJumpReady" as const
463+
return "AirJumpSpent" as const
480464
}
481465

482466
export const wallContact = (snapshot: CharacterSnapshot) => {
483-
return snapshot.states.contact.state.value._tag
467+
const path = snapshot.states.contact.state.path
468+
if (path === "Character.contact.NoWall") return "NoWall" as const
469+
return path === "Character.contact.LeftWall" ? "LeftWall" as const : "RightWall" as const
484470
}
485471

486-
export const facingDirection = (snapshot: CharacterSnapshot) => snapshot.states.facing.state.value._tag
472+
export const facingDirection = (snapshot: CharacterSnapshot) =>
473+
snapshot.states.facing.state.path === "Character.facing.Left" ? "Left" as const : "Right" as const
487474

488475
export const activeStateData = (snapshot: CharacterSnapshot) => {
489476
const locomotion = snapshot.states.locomotion.state
@@ -493,15 +480,16 @@ export const activeStateData = (snapshot: CharacterSnapshot) => {
493480
}
494481

495482
const playing = locomotion.state
496-
const { _tag: _branch, ...branchData } = playing.value
497483
if (playing.path === "Character.locomotion.Playing.Grounded") {
484+
if (playing.state.path === "Character.locomotion.Playing.Grounded.Standing") return {}
498485
const { _tag: _leaf, ...leafData } = playing.state.value
499-
return { ...branchData, ...leafData }
486+
return leafData
500487
}
488+
const { _tag: _branch, ...branchData } = playing.value
501489
const { _tag: _motion, ...motionData } = playing.states.motion.state.value
502490
return {
503491
...branchData,
504492
...motionData,
505-
airJump: playing.states.airJump.state.value._tag
493+
airJump: airJumpMode(snapshot)
506494
}
507495
}

0 commit comments

Comments
 (0)