Skip to content

Commit 3646067

Browse files
feat: omit empty state inputs
1 parent 001201b commit 3646067

6 files changed

Lines changed: 490 additions & 73 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Allow state builder `.from()` calls to omit the constructor input when the
6+
selected schema accepts `{}`. Required fields and compound or parallel child
7+
selection remain type-safe, and omitted inputs still run through schema
8+
construction during planning.

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ const Counter = Machine.make({
5555
id: "Counter",
5656
states: States.states,
5757
events: [Event.cases.Start],
58-
initial: () => States.initial.Idle.from({})
58+
initial: () => States.initial.Idle.from()
5959
}).handle({
6060
Idle: {
6161
on: {
62-
Start: ({ target }) => target.full.Running.from({})
62+
Start: ({ target }) => target.full.Running.from()
6363
}
6464
},
6565
Running: {}
@@ -86,6 +86,16 @@ form is available on initial, local, branch, full, compound, parallel, and
8686
final builders. A `.from` builder result is therefore a machine construction
8787
instruction; it becomes a validated public snapshot when planning succeeds.
8888

89+
When `{}` is valid constructor input, omit it. Required state fields remain
90+
required, while compound and parallel states still require their active-child
91+
callback:
92+
93+
```ts
94+
States.initial.Idle.from()
95+
States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
96+
States.initial.Flow.from((flow) => flow.Idle.from())
97+
```
98+
8999
Tagged classes are equally valid when cases need class methods or nominal
90100
identity:
91101

@@ -112,7 +122,7 @@ const machine = Machine.make({
112122
states: States.states,
113123
events: [Command.cases.Save],
114124
internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
115-
initial: () => States.initial.Idle(State.cases.Idle.make({}))
125+
initial: () => States.initial.Idle.from()
116126
})
117127
```
118128

@@ -174,7 +184,7 @@ Handlers implement behavior and output computation without repeating it:
174184
const machine = Machine.make({
175185
states: States.states,
176186
events: [],
177-
initial: () => States.initial.Form.from({ draft: "" }, (form) => form.Editing.from({}))
187+
initial: () => States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
178188
}).handle({
179189
Form: {
180190
states: {
@@ -246,7 +256,7 @@ effects in `Machine.action`; actions are staged during planning and run by the
246256
managed runtime before it publishes the next state.
247257

248258
```ts
249-
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving(State.cases.Saving.make({})))
259+
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving.from())
250260
```
251261

252262
The one-argument form returns `void` after staging. The two-argument form

docs/agent-guide.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,19 @@ boundary rather than throwing synchronously. This applies recursively to
246246
initial, full, local, branch, compound, parallel, final, and `local.with`
247247
builders.
248248

249+
If `{}` satisfies the schema's constructor input, omit it:
250+
251+
```ts
252+
target.local.Idle.from()
253+
target.local.Flow.from((flow) => flow.Idle.from())
254+
```
255+
256+
This shorthand also applies to schemas whose constructor fields are all
257+
optional or defaulted. It does not make required fields optional. Compound and
258+
parallel builders still require a callback selecting their active child or
259+
every active region. Omitted input is normalized to `{}` and still passes
260+
through `schema.makeEffect`, including refinements.
261+
249262
## Reading state and parents
250263

251264
`Machine.defineStates` returns typed helpers:

src/Machine.ts

Lines changed: 94 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -536,20 +536,43 @@ type SnapshotBuilderComplete<Regions, Constructed extends boolean = false> = {
536536
readonly [SnapshotBuilderConstructionTypeId]: Constructed
537537
}
538538

539+
type FromCallable<Arguments extends ReadonlyArray<unknown>, Result> = Arguments extends
540+
readonly [infer Input, ...infer Rest extends ReadonlyArray<unknown>] ? {} extends Input ? {
541+
(...args: Rest): Result
542+
(...args: Arguments): Result
543+
}
544+
: (...args: Arguments) => Result
545+
: (...args: Arguments) => Result
546+
539547
type FromMethod<Arguments extends ReadonlyArray<unknown>, Result> = {
540548
/**
541549
* Constructs the selected state from its schema make input while the
542-
* machine plans the resulting configuration.
550+
* machine plans the resulting configuration. The input may be omitted when
551+
* the schema accepts an empty constructor object.
543552
*
544553
* @since 4.0.0
545554
*/
546-
readonly from: (...args: Arguments) => Machine.StateConstruction<Result>
555+
readonly from: FromCallable<Arguments, Machine.StateConstruction<Result>>
547556
}
548557

549558
type ConstructionResult<Result> = Result | Machine.StateConstruction<Result>
550559

551560
type UnwrapConstruction<Result> = Result extends Machine.StateConstruction<infer Value> ? Value : Result
552561

562+
type ConstructionSelectorFromCallable<Input, Builder, Result> = {} extends Input ? {
563+
<Selected extends ConstructionResult<Result>>(
564+
state: (builder: Builder) => Selected
565+
): Machine.StateConstruction<UnwrapConstruction<Selected>>
566+
<Selected extends ConstructionResult<Result>>(
567+
input: Input,
568+
state: (builder: Builder) => Selected
569+
): Machine.StateConstruction<UnwrapConstruction<Selected>>
570+
}
571+
: <Selected extends ConstructionResult<Result>>(
572+
input: Input,
573+
state: (builder: Builder) => Selected
574+
) => Machine.StateConstruction<UnwrapConstruction<Selected>>
575+
553576
type InitialSnapshotBuilderWithPrefix<
554577
States extends Machine.StateSchemas,
555578
Prefix extends string = ""
@@ -665,14 +688,15 @@ type InitialParallelBuilder<
665688
Constructed
666689
>)
667690
& {
668-
readonly from: (
669-
...args: InitialSnapshotFromArguments<States, Key, Prefix>
670-
) => InitialParallelBuilder<
671-
States,
672-
Prefix,
673-
Exclude<Remaining, Key>,
674-
Regions & { readonly [Region in Key]: InitialSnapshotResult<States, Key, Prefix> },
675-
true
691+
readonly from: FromCallable<
692+
InitialSnapshotFromArguments<States, Key, Prefix>,
693+
InitialParallelBuilder<
694+
States,
695+
Prefix,
696+
Exclude<Remaining, Key>,
697+
Regions & { readonly [Region in Key]: InitialSnapshotResult<States, Key, Prefix> },
698+
true
699+
>
676700
>
677701
}
678702
}
@@ -766,14 +790,15 @@ type FullParallelBuilder<
766790
Constructed
767791
>)
768792
& {
769-
readonly from: (
770-
...args: FullSnapshotFromArguments<States, Key, Prefix>
771-
) => FullParallelBuilder<
772-
States,
773-
Prefix,
774-
Exclude<Remaining, Key>,
775-
Regions & { readonly [Region in Key]: FullSnapshotResult<States, Key, Prefix> },
776-
true
793+
readonly from: FromCallable<
794+
FullSnapshotFromArguments<States, Key, Prefix>,
795+
FullParallelBuilder<
796+
States,
797+
Prefix,
798+
Exclude<Remaining, Key>,
799+
Regions & { readonly [Region in Key]: FullSnapshotResult<States, Key, Prefix> },
800+
true
801+
>
777802
>
778803
}
779804
}
@@ -852,12 +877,11 @@ type LocalTargetMethod<
852877
) => Result
853878
) => Result)
854879
& {
855-
readonly from: <Result extends ConstructionResult<LocalTargetResultWithPrefix<AllStates, Children, Path>>>(
856-
input: Machine.NodeSchema<Node>["~type.make.in"],
857-
state: (
858-
builder: LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>
859-
) => Result
860-
) => Machine.StateConstruction<UnwrapConstruction<Result>>
880+
readonly from: ConstructionSelectorFromCallable<
881+
Machine.NodeSchema<Node>["~type.make.in"],
882+
LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
883+
LocalTargetResultWithPrefix<AllStates, Children, Path>
884+
>
861885
}
862886
:
863887
& ((
@@ -883,12 +907,11 @@ type LocalTargetMethod<
883907
) => Result
884908
) => Result)
885909
& {
886-
readonly from: <Result extends ConstructionResult<LocalTargetResultWithPrefix<AllStates, Children, Path>>>(
887-
input: Machine.NodeSchema<Node>["~type.make.in"],
888-
state: (
889-
builder: LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>
890-
) => Result
891-
) => Machine.StateConstruction<UnwrapConstruction<Result>>
910+
readonly from: ConstructionSelectorFromCallable<
911+
Machine.NodeSchema<Node>["~type.make.in"],
912+
LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
913+
LocalTargetResultWithPrefix<AllStates, Children, Path>
914+
>
892915
}
893916
:
894917
& ((value: Machine.NodeSchema<Node>["Type"]) => Machine.Target<
@@ -922,12 +945,11 @@ type LocalTargetBuilderForScope<
922945
) => Result
923946
) => Result)
924947
& {
925-
readonly from: <Result extends ConstructionResult<LocalTargetResultWithPrefix<States, Children, Scope>>>(
926-
input: Machine.SchemaByIdentifier<States, Scope>["~type.make.in"],
927-
state: (
928-
builder: LocalTargetBuilderWithPrefix<States, Children, Scope, Source>
929-
) => Result
930-
) => Machine.StateConstruction<UnwrapConstruction<Result>>
948+
readonly from: ConstructionSelectorFromCallable<
949+
Machine.SchemaByIdentifier<States, Scope>["~type.make.in"],
950+
LocalTargetBuilderWithPrefix<States, Children, Scope, Source>,
951+
LocalTargetResultWithPrefix<States, Children, Scope>
952+
>
931953
}
932954
}
933955
: {}
@@ -978,12 +1000,11 @@ type BranchTargetMethod<
9781000
) => Result
9791001
) => Result)
9801002
& {
981-
readonly from: <Result extends ConstructionResult<BranchTargetResultWithPrefix<AllStates, Children, Path>>>(
982-
input: Machine.NodeSchema<Node>["~type.make.in"],
983-
state: (
984-
builder: BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
985-
) => Result
986-
) => Machine.StateConstruction<UnwrapConstruction<Result>>
1003+
readonly from: ConstructionSelectorFromCallable<
1004+
Machine.NodeSchema<Node>["~type.make.in"],
1005+
BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
1006+
BranchTargetResultWithPrefix<AllStates, Children, Path>
1007+
>
9871008
}
9881009
& BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
9891010
:
@@ -1010,12 +1031,11 @@ type BranchTargetMethod<
10101031
) => Result
10111032
) => Result)
10121033
& {
1013-
readonly from: <Result extends ConstructionResult<BranchTargetResultWithPrefix<AllStates, Children, Path>>>(
1014-
input: Machine.NodeSchema<Node>["~type.make.in"],
1015-
state: (
1016-
builder: BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
1017-
) => Result
1018-
) => Machine.StateConstruction<UnwrapConstruction<Result>>
1034+
readonly from: ConstructionSelectorFromCallable<
1035+
Machine.NodeSchema<Node>["~type.make.in"],
1036+
BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
1037+
BranchTargetResultWithPrefix<AllStates, Children, Path>
1038+
>
10191039
}
10201040
& BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
10211041
:
@@ -4058,15 +4078,22 @@ type SnapshotBuilderOptions = {
40584078
readonly prefix: string
40594079
}
40604080

4081+
type FromMethodKind = "leaf" | "nested"
4082+
40614083
const withFrom = <Method extends (value: unknown, ...args: ReadonlyArray<any>) => unknown>(
4062-
method: Method
4063-
): Method & { readonly from: (input: unknown, ...args: ReadonlyArray<any>) => unknown } => {
4084+
method: Method,
4085+
kind: FromMethodKind
4086+
): Method & { readonly from: (...args: ReadonlyArray<any>) => unknown } => {
40644087
Object.defineProperty(method, "from", {
4065-
value: (input: unknown, ...args: ReadonlyArray<any>) =>
4066-
Model.markStateConstruction(method(Model.makeStateInput(input), ...args)),
4088+
value: (...args: ReadonlyArray<any>) => {
4089+
const omitted = args.length === 0 || (kind === "nested" && args.length === 1 && typeof args[0] === "function")
4090+
const input = omitted ? {} : args[0]
4091+
const rest = omitted ? args : args.slice(1)
4092+
return Model.markStateConstruction(method(Model.makeStateInput(input), ...rest))
4093+
},
40674094
enumerable: false
40684095
})
4069-
return method as Method & { readonly from: (input: unknown, ...args: ReadonlyArray<any>) => unknown }
4096+
return method as Method & { readonly from: (...args: ReadonlyArray<any>) => unknown }
40704097
}
40714098

40724099
const makeSnapshotBuilder = (
@@ -4075,8 +4102,12 @@ const makeSnapshotBuilder = (
40754102
): unknown => {
40764103
const builder: Record<string, unknown> = {}
40774104
for (const key of Object.keys(states)) {
4078-
builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) =>
4079-
makeSnapshotForNode(states[key], key, value, selector, options)
4105+
const path = options.prefix === "" ? key : `${options.prefix}.${key}`
4106+
const node = Model.getStateNodeDefinition(path, states[key])
4107+
builder[key] = withFrom(
4108+
(value: unknown, selector?: (builder: unknown) => unknown) =>
4109+
makeSnapshotForNode(states[key], key, value, selector, options),
4110+
node.states === undefined ? "leaf" : "nested"
40804111
)
40814112
}
40824113
return builder
@@ -4100,6 +4131,8 @@ const makeParallelSnapshotBuilder = (
41004131
if (hasProperty(regions, key)) {
41014132
continue
41024133
}
4134+
const path = options.prefix === "" ? key : `${options.prefix}.${key}`
4135+
const node = Model.getStateNodeDefinition(path, states[key])
41034136
builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => {
41044137
const nextRegions: Record<string, unknown> = {}
41054138
for (const regionKey of Object.keys(regions)) {
@@ -4108,7 +4141,7 @@ const makeParallelSnapshotBuilder = (
41084141
nextRegions[key] = makeSnapshotForNode(states[key], key, value, selector, options)
41094142
const next = makeParallelSnapshotBuilder(states, options, nextRegions)
41104143
return Model.isStateConstruction(builder) ? Model.markStateConstruction(next) : next
4111-
})
4144+
}, node.states === undefined ? "leaf" : "nested")
41124145
}
41134146
return builder
41144147
}
@@ -4304,7 +4337,7 @@ const makeLocalTargetChildBuilder = (
43044337
extendTargetValues(values, child.path, value),
43054338
source
43064339
))
4307-
})
4340+
}, child.type === "atomic" || child.type === "final" ? "leaf" : "nested")
43084341
}
43094342
return builder
43104343
}
@@ -4324,7 +4357,7 @@ const makeLocalTargetBuilder = (
43244357
throw new Error(`Machine expected target "${scope}" builder to provide an active child state`)
43254358
}
43264359
return selector(makeLocalTargetChildBuilder(states, stateNodes, scope, { [scope]: value }, source))
4327-
})
4360+
}, "nested")
43284361
return builder
43294362
}
43304363

@@ -4352,7 +4385,7 @@ const makeBranchTargetNodeBuilder = (
43524385
): unknown => {
43534386
const node = getTargetBuilderNode(stateNodes, path)
43544387
if (node.type === "atomic" || node.type === "final") {
4355-
return withFrom((value: unknown) => makeTargetWithValues(node.path, value, values))
4388+
return withFrom((value: unknown) => makeTargetWithValues(node.path, value, values), "leaf")
43564389
}
43574390
const builder = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => {
43584391
if (node.type === "parallel") {
@@ -4386,7 +4419,7 @@ const makeBranchTargetNodeBuilder = (
43864419
source
43874420
)
43884421
return selector(nextBuilder)
4389-
}) as unknown as Record<string, unknown>
4422+
}, "nested") as unknown as Record<string, unknown>
43904423
if (node.type !== "parallel" || source === node.path || source.startsWith(`${node.path}.`)) {
43914424
addBranchTargetChildren(builder, states, stateNodes, node.path, values, source)
43924425
}
@@ -4445,7 +4478,7 @@ const makeTargetBuilder = <const States extends Machine.StateSchemas>(
44454478
* Machine.make({
44464479
* states: States.states,
44474480
* events: [],
4448-
* initial: () => States.initial.idle(new Idle({}))
4481+
* initial: () => States.initial.idle.from()
44494482
* })
44504483
* ```
44514484
*

0 commit comments

Comments
 (0)