Skip to content

Commit 944cdb5

Browse files
Support unbounded conditional transition cases (#127)
1 parent 2c67924 commit 944cdb5

18 files changed

Lines changed: 482 additions & 212 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Allow conditional `Machine.transition` definitions to infer any number of heterogeneous cases. Define `cases` with its locally supplied `branch` constructor so each predicate match and selected target remain exact in the corresponding resolver:
6+
7+
```ts
8+
Machine.transition({
9+
cases: (branch) => [
10+
branch({
11+
title: "cached",
12+
when: ({ event }) => event.cached,
13+
target: (to) => to.full.Ready(),
14+
resolve: ({ match, target }) => target.from({ data: match })
15+
})
16+
],
17+
otherwise: {
18+
target: (to) => to.full.Loading(),
19+
resolve: ({ target }) => target.from()
20+
}
21+
})
22+
```
23+
24+
Replace each object previously written directly in the `cases` array with `branch({ ... })` inside the `cases: (branch) => [...]` factory. Direct transitions and `otherwise` keep their existing shape.

docs/agent-guide.md

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -496,15 +496,17 @@ microstep, before any selected transition is applied:
496496

497497
```ts
498498
BufferReady: Machine.transition({
499-
cases: [{
500-
title: "online",
501-
when: ({ snapshot }) =>
502-
States.matches(snapshot, "Player.Network.Online")
503-
? Option.some(undefined)
504-
: Option.none(),
505-
target: (to) => to.local.Playing(),
506-
resolve: ({ target }) => target.from()
507-
}],
499+
cases: (branch) => [
500+
branch({
501+
title: "online",
502+
when: ({ snapshot }) =>
503+
States.matches(snapshot, "Player.Network.Online")
504+
? Option.some(undefined)
505+
: Option.none(),
506+
target: (to) => to.local.Playing(),
507+
resolve: ({ target }) => target.from()
508+
})
509+
],
508510
otherwise: {
509511
target: (to) => to.none(),
510512
resolve: () => undefined
@@ -564,12 +566,14 @@ synchronously:
564566

565567
```ts
566568
Submit: Machine.transition({
567-
cases: [{
568-
title: "valid",
569-
when: ({ state }) => state.valid ? Option.some(state.draft) : Option.none(),
570-
target: (to) => to.local.Saving(),
571-
resolve: ({ match, target }) => target.from({ draft: match })
572-
}],
569+
cases: (branch) => [
570+
branch({
571+
title: "valid",
572+
when: ({ state }) => state.valid ? Option.some(state.draft) : Option.none(),
573+
target: (to) => to.local.Saving(),
574+
resolve: ({ match, target }) => target.from({ draft: match })
575+
})
576+
],
573577
otherwise: {
574578
target: (to) => to.none(),
575579
resolve: () => undefined
@@ -580,9 +584,12 @@ Submit: Machine.transition({
580584
Every installed event, `always`, `onDone`, choice, and invoke lifecycle handler
581585
must use `Machine.transition`. Each direct branch declares one `target`; a
582586
conditional transition declares ordered `cases` and a required `otherwise`.
583-
`when` returns `Option.some(match)` to select a case and infer `match` in its
584-
resolver. Selecting `to.none()` handles the transition without a destination,
585-
while retaining queued commands, raised events, and emitted events.
587+
Construct each case with the locally supplied `branch` function. Every call
588+
independently infers its `when` match and target, so a transition may declare
589+
any number of heterogeneous cases without losing resolver inference. `when`
590+
returns `Option.some(match)` to select a case and expose `match` in its resolver.
591+
Selecting `to.none()` handles the transition without a destination, while
592+
retaining queued commands, raised events, and emitted events.
586593

587594
`reenter: true` remains meaningful with `to.none()`: the source exits and
588595
enters again while its logical configuration is retained.

examples/platformer/src/machine.ts

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,12 @@ export const CharacterMachine = definition.handle({
207207
Standing: {
208208
on: {
209209
Move: Machine.transition({
210-
cases: [{
210+
cases: (branch) => [branch({
211211
title: "moving",
212212
when: ({ event }) => event.axis === 0 ? Option.none() : Option.some(event),
213213
target: (to) => to.local.Running(),
214214
resolve: ({ match, target }) => target.from({ startedAt: match.at })
215-
}],
215+
})],
216216
otherwise: {
217217
target: (to) => to.none(),
218218
resolve: () => undefined
@@ -227,12 +227,12 @@ export const CharacterMachine = definition.handle({
227227
Running: {
228228
on: {
229229
Move: Machine.transition({
230-
cases: [{
230+
cases: (branch) => [branch({
231231
title: "stopped",
232232
when: ({ event }) => event.axis === 0 ? Option.some(undefined) : Option.none(),
233233
target: (to) => to.local.Standing(),
234234
resolve: ({ target }) => target.from()
235-
}],
235+
})],
236236
otherwise: {
237237
target: (to) => to.none(),
238238
resolve: () => undefined
@@ -247,12 +247,12 @@ export const CharacterMachine = definition.handle({
247247
Ducking: {
248248
on: {
249249
DownReleased: Machine.transition({
250-
cases: [{
250+
cases: (branch) => [branch({
251251
title: "stopped",
252252
when: ({ event }) => event.axis === 0 ? Option.some(undefined) : Option.none(),
253253
target: (to) => to.local.Standing(),
254254
resolve: ({ target }) => target.from()
255-
}],
255+
})],
256256
otherwise: {
257257
target: (to) => to.local.Running(),
258258
resolve: ({ event, target }) => target.from({ startedAt: event.at })
@@ -274,12 +274,12 @@ export const CharacterMachine = definition.handle({
274274
}),
275275
on: {
276276
LandingSettled: Machine.transition({
277-
cases: [{
277+
cases: (branch) => [branch({
278278
title: "stopped",
279279
when: ({ state }) => state.resumeAxis === 0 ? Option.some(undefined) : Option.none(),
280280
target: (to) => to.local.Standing(),
281281
resolve: ({ target }) => target.from()
282-
}],
282+
})],
283283
otherwise: {
284284
target: (to) => to.local.Running(),
285285
resolve: ({ state, target }) => target.from({ startedAt: state.landedAt + 140 })
@@ -423,43 +423,43 @@ export const CharacterMachine = definition.handle({
423423
Left: {
424424
on: {
425425
Move: Machine.transition({
426-
cases: [{
426+
cases: (branch) => [branch({
427427
title: "right",
428428
when: ({ event }) => event.axis === 1 ? Option.some(undefined) : Option.none(),
429429
target: (to) => to.local.Right(),
430430
resolve: ({ target }) => target.from()
431-
}],
431+
})],
432432
otherwise: { target: (to) => to.none(), resolve: () => undefined }
433433
}),
434434
WallJump: Machine.transition({
435-
cases: [{
435+
cases: (branch) => [branch({
436436
title: "right",
437437
when: ({ event }) => event.push === 1 ? Option.some(undefined) : Option.none(),
438438
target: (to) => to.local.Right(),
439439
resolve: ({ target }) => target.from()
440-
}],
440+
})],
441441
otherwise: { target: (to) => to.none(), resolve: () => undefined }
442442
})
443443
}
444444
},
445445
Right: {
446446
on: {
447447
Move: Machine.transition({
448-
cases: [{
448+
cases: (branch) => [branch({
449449
title: "left",
450450
when: ({ event }) => event.axis === -1 ? Option.some(undefined) : Option.none(),
451451
target: (to) => to.local.Left(),
452452
resolve: ({ target }) => target.from()
453-
}],
453+
})],
454454
otherwise: { target: (to) => to.none(), resolve: () => undefined }
455455
}),
456456
WallJump: Machine.transition({
457-
cases: [{
457+
cases: (branch) => [branch({
458458
title: "left",
459459
when: ({ event }) => event.push === -1 ? Option.some(undefined) : Option.none(),
460460
target: (to) => to.local.Left(),
461461
resolve: ({ target }) => target.from()
462-
}],
462+
})],
463463
otherwise: { target: (to) => to.none(), resolve: () => undefined }
464464
})
465465
}
@@ -469,17 +469,20 @@ export const CharacterMachine = definition.handle({
469469
contact: {
470470
on: {
471471
WallContact: Machine.transition({
472-
cases: [{
473-
title: "left wall",
474-
when: ({ event }) => event.wall === -1 ? Option.some(undefined) : Option.none(),
475-
target: (to) => to.local.LeftWall(),
476-
resolve: ({ target }) => target.from()
477-
}, {
478-
title: "right wall",
479-
when: ({ event }) => event.wall === 1 ? Option.some(undefined) : Option.none(),
480-
target: (to) => to.local.RightWall(),
481-
resolve: ({ target }) => target.from()
482-
}],
472+
cases: (branch) => [
473+
branch({
474+
title: "left wall",
475+
when: ({ event }) => event.wall === -1 ? Option.some(undefined) : Option.none(),
476+
target: (to) => to.local.LeftWall(),
477+
resolve: ({ target }) => target.from()
478+
}),
479+
branch({
480+
title: "right wall",
481+
when: ({ event }) => event.wall === 1 ? Option.some(undefined) : Option.none(),
482+
target: (to) => to.local.RightWall(),
483+
resolve: ({ target }) => target.from()
484+
})
485+
],
483486
otherwise: {
484487
target: (to) => to.local.NoWall(),
485488
resolve: ({ target }) => target.from()

examples/playground/src/examples/microwave/machine.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ export const MicrowaveMachine = definition.handle({
5858
Idle: {
5959
on: {
6060
PowerPressed: Machine.transition({
61-
cases: [{
61+
cases: (branch) => [branch({
6262
title: "door closed",
6363
when: ({ snapshot }) =>
6464
MicrowaveStates.matches(snapshot, "Oven.door.Closed")
6565
? Option.some(undefined)
6666
: Option.none(),
6767
target: (to) => to.local.Cooking(),
6868
resolve: ({ target }) => target.from({ elapsedSeconds: 0 })
69-
}],
69+
})],
7070
otherwise: { target: (to) => to.none(), resolve: () => undefined }
7171
})
7272
}

examples/playground/src/examples/worker-tabs/machine.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ export const SharedMachine = definition.handle({
4646
resolve: ({ target }) => target.from({ count: 0 })
4747
}),
4848
Synchronized: Machine.transition({
49-
cases: [{
49+
cases: (branch) => [branch({
5050
title: "active",
5151
when: ({ event }) => event.active ? Option.some(event.count) : Option.none(),
5252
target: (to) => to.full.Active(),
5353
resolve: ({ match, target }) => target.from({ count: match })
54-
}],
54+
})],
5555
otherwise: {
5656
target: (to) => to.full.Idle(),
5757
resolve: ({ event, target }) => target.from({ count: event.count })
@@ -74,12 +74,12 @@ export const SharedMachine = definition.handle({
7474
resolve: ({ state, target }) => target.from({ count: state.count })
7575
}),
7676
Synchronized: Machine.transition({
77-
cases: [{
77+
cases: (branch) => [branch({
7878
title: "active",
7979
when: ({ event }) => event.active ? Option.some(event.count) : Option.none(),
8080
target: (to) => to.full.Active(),
8181
resolve: ({ match, target }) => target.from({ count: match })
82-
}],
82+
})],
8383
otherwise: {
8484
target: (to) => to.full.Idle(),
8585
resolve: ({ event, target }) => target.from({ count: event.count })

examples/pokemon/src/machines/selection.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,12 @@ export const SelectionMachine = definition.handle({
132132
}),
133133
on: {
134134
SearchResult: Machine.transition({
135-
cases: [{
135+
cases: (branch) => [branch({
136136
title: "found",
137137
when: ({ event }) => event.result,
138138
target: (to) => to.local.WithPokemon(),
139139
resolve: ({ match, target }) => target.from({ pokemon: match })
140-
}],
140+
})],
141141
otherwise: {
142142
target: (to) => to.local.NoPokemon(),
143143
resolve: ({ target }) => target.from()
@@ -160,12 +160,12 @@ export const SelectionMachine = definition.handle({
160160
Selected: {
161161
on: {
162162
SelectPokemon: Machine.transition({
163-
cases: [{
163+
cases: (branch) => [branch({
164164
title: "already selected",
165165
when: ({ event, state }) => state.id === event.id ? Option.some(undefined) : Option.none(),
166166
target: (to) => to.local.Unselected(),
167167
resolve: ({ target }) => target.from()
168-
}],
168+
})],
169169
otherwise: {
170170
target: (to) => to.local.Selected(),
171171
resolve: ({ event, target }) => target.from({ id: event.id })
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { Schema } from "effect"
3+
4+
export const State = Schema.TaggedUnion({
5+
Idle: {},
6+
Text: { value: Schema.String },
7+
Count: { value: Schema.Number }
8+
})
9+
10+
export const Route = Schema.TaggedStruct("Route", { value: Schema.String })
11+
export const States = Machine.defineStates(State.cases)
12+
13+
export const machine = Machine.make({
14+
states: States.states,
15+
events: Machine.events(Route),
16+
initial: {
17+
target: (to) => to.Idle(),
18+
resolve: ({ target }) => target(State.cases.Idle.make({}))
19+
}
20+
})

0 commit comments

Comments
 (0)