Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/tidy-states-omit-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@typeonce/effect-machine": minor
---

Allow state builder `.from()` calls to omit the constructor input when the
selected schema accepts `{}`. Required fields and compound or parallel child
selection remain type-safe, and omitted inputs still run through schema
construction during planning.
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ const Counter = Machine.make({
id: "Counter",
states: States.states,
events: [Event.cases.Start],
initial: () => States.initial.Idle.from({})
initial: () => States.initial.Idle.from()
}).handle({
Idle: {
on: {
Start: ({ target }) => target.full.Running.from({})
Start: ({ target }) => target.full.Running.from()
}
},
Running: {}
Expand All @@ -86,6 +86,16 @@ form is available on initial, local, branch, full, compound, parallel, and
final builders. A `.from` builder result is therefore a machine construction
instruction; it becomes a validated public snapshot when planning succeeds.

When `{}` is valid constructor input, omit it. Required state fields remain
required, while compound and parallel states still require their active-child
callback:

```ts
States.initial.Idle.from()
States.initial.Form.from({ draft: "" }, (form) => form.Editing.from())
States.initial.Flow.from((flow) => flow.Idle.from())
```

Tagged classes are equally valid when cases need class methods or nominal
identity:

Expand All @@ -112,7 +122,7 @@ const machine = Machine.make({
states: States.states,
events: [Command.cases.Save],
internalEvents: [InternalEvent.cases.Saved, InternalEvent.cases.SaveFailed],
initial: () => States.initial.Idle(State.cases.Idle.make({}))
initial: () => States.initial.Idle.from()
})
```

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

```ts
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving(State.cases.Saving.make({})))
Save: ({ target }) => Machine.action(writeAuditLog, target.local.Saving.from())
```

The one-argument form returns `void` after staging. The two-argument form
Expand Down
13 changes: 13 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,19 @@ boundary rather than throwing synchronously. This applies recursively to
initial, full, local, branch, compound, parallel, final, and `local.with`
builders.

If `{}` satisfies the schema's constructor input, omit it:

```ts
target.local.Idle.from()
target.local.Flow.from((flow) => flow.Idle.from())
```

This shorthand also applies to schemas whose constructor fields are all
optional or defaulted. It does not make required fields optional. Compound and
parallel builders still require a callback selecting their active child or
every active region. Omitted input is normalized to `{}` and still passes
through `schema.makeEffect`, including refinements.

## Reading state and parents

`Machine.defineStates` returns typed helpers:
Expand Down
155 changes: 94 additions & 61 deletions src/Machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,20 +536,43 @@ type SnapshotBuilderComplete<Regions, Constructed extends boolean = false> = {
readonly [SnapshotBuilderConstructionTypeId]: Constructed
}

type FromCallable<Arguments extends ReadonlyArray<unknown>, Result> = Arguments extends
readonly [infer Input, ...infer Rest extends ReadonlyArray<unknown>] ? {} extends Input ? {
(...args: Rest): Result
(...args: Arguments): Result
}
: (...args: Arguments) => Result
: (...args: Arguments) => Result

type FromMethod<Arguments extends ReadonlyArray<unknown>, Result> = {
/**
* Constructs the selected state from its schema make input while the
* machine plans the resulting configuration.
* machine plans the resulting configuration. The input may be omitted when
* the schema accepts an empty constructor object.
*
* @since 4.0.0
*/
readonly from: (...args: Arguments) => Machine.StateConstruction<Result>
readonly from: FromCallable<Arguments, Machine.StateConstruction<Result>>
}

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

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

type ConstructionSelectorFromCallable<Input, Builder, Result> = {} extends Input ? {
<Selected extends ConstructionResult<Result>>(
state: (builder: Builder) => Selected
): Machine.StateConstruction<UnwrapConstruction<Selected>>
<Selected extends ConstructionResult<Result>>(
input: Input,
state: (builder: Builder) => Selected
): Machine.StateConstruction<UnwrapConstruction<Selected>>
}
: <Selected extends ConstructionResult<Result>>(
input: Input,
state: (builder: Builder) => Selected
) => Machine.StateConstruction<UnwrapConstruction<Selected>>

type InitialSnapshotBuilderWithPrefix<
States extends Machine.StateSchemas,
Prefix extends string = ""
Expand Down Expand Up @@ -665,14 +688,15 @@ type InitialParallelBuilder<
Constructed
>)
& {
readonly from: (
...args: InitialSnapshotFromArguments<States, Key, Prefix>
) => InitialParallelBuilder<
States,
Prefix,
Exclude<Remaining, Key>,
Regions & { readonly [Region in Key]: InitialSnapshotResult<States, Key, Prefix> },
true
readonly from: FromCallable<
InitialSnapshotFromArguments<States, Key, Prefix>,
InitialParallelBuilder<
States,
Prefix,
Exclude<Remaining, Key>,
Regions & { readonly [Region in Key]: InitialSnapshotResult<States, Key, Prefix> },
true
>
>
}
}
Expand Down Expand Up @@ -766,14 +790,15 @@ type FullParallelBuilder<
Constructed
>)
& {
readonly from: (
...args: FullSnapshotFromArguments<States, Key, Prefix>
) => FullParallelBuilder<
States,
Prefix,
Exclude<Remaining, Key>,
Regions & { readonly [Region in Key]: FullSnapshotResult<States, Key, Prefix> },
true
readonly from: FromCallable<
FullSnapshotFromArguments<States, Key, Prefix>,
FullParallelBuilder<
States,
Prefix,
Exclude<Remaining, Key>,
Regions & { readonly [Region in Key]: FullSnapshotResult<States, Key, Prefix> },
true
>
>
}
}
Expand Down Expand Up @@ -852,12 +877,11 @@ type LocalTargetMethod<
) => Result
) => Result)
& {
readonly from: <Result extends ConstructionResult<LocalTargetResultWithPrefix<AllStates, Children, Path>>>(
input: Machine.NodeSchema<Node>["~type.make.in"],
state: (
builder: LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>
) => Result
) => Machine.StateConstruction<UnwrapConstruction<Result>>
readonly from: ConstructionSelectorFromCallable<
Machine.NodeSchema<Node>["~type.make.in"],
LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
LocalTargetResultWithPrefix<AllStates, Children, Path>
>
}
:
& ((
Expand All @@ -883,12 +907,11 @@ type LocalTargetMethod<
) => Result
) => Result)
& {
readonly from: <Result extends ConstructionResult<LocalTargetResultWithPrefix<AllStates, Children, Path>>>(
input: Machine.NodeSchema<Node>["~type.make.in"],
state: (
builder: LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>
) => Result
) => Machine.StateConstruction<UnwrapConstruction<Result>>
readonly from: ConstructionSelectorFromCallable<
Machine.NodeSchema<Node>["~type.make.in"],
LocalTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
LocalTargetResultWithPrefix<AllStates, Children, Path>
>
}
:
& ((value: Machine.NodeSchema<Node>["Type"]) => Machine.Target<
Expand Down Expand Up @@ -922,12 +945,11 @@ type LocalTargetBuilderForScope<
) => Result
) => Result)
& {
readonly from: <Result extends ConstructionResult<LocalTargetResultWithPrefix<States, Children, Scope>>>(
input: Machine.SchemaByIdentifier<States, Scope>["~type.make.in"],
state: (
builder: LocalTargetBuilderWithPrefix<States, Children, Scope, Source>
) => Result
) => Machine.StateConstruction<UnwrapConstruction<Result>>
readonly from: ConstructionSelectorFromCallable<
Machine.SchemaByIdentifier<States, Scope>["~type.make.in"],
LocalTargetBuilderWithPrefix<States, Children, Scope, Source>,
LocalTargetResultWithPrefix<States, Children, Scope>
>
}
}
: {}
Expand Down Expand Up @@ -978,12 +1000,11 @@ type BranchTargetMethod<
) => Result
) => Result)
& {
readonly from: <Result extends ConstructionResult<BranchTargetResultWithPrefix<AllStates, Children, Path>>>(
input: Machine.NodeSchema<Node>["~type.make.in"],
state: (
builder: BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
) => Result
) => Machine.StateConstruction<UnwrapConstruction<Result>>
readonly from: ConstructionSelectorFromCallable<
Machine.NodeSchema<Node>["~type.make.in"],
BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
BranchTargetResultWithPrefix<AllStates, Children, Path>
>
}
& BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
:
Expand All @@ -1010,12 +1031,11 @@ type BranchTargetMethod<
) => Result
) => Result)
& {
readonly from: <Result extends ConstructionResult<BranchTargetResultWithPrefix<AllStates, Children, Path>>>(
input: Machine.NodeSchema<Node>["~type.make.in"],
state: (
builder: BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
) => Result
) => Machine.StateConstruction<UnwrapConstruction<Result>>
readonly from: ConstructionSelectorFromCallable<
Machine.NodeSchema<Node>["~type.make.in"],
BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>,
BranchTargetResultWithPrefix<AllStates, Children, Path>
>
}
& BranchTargetBuilderWithPrefix<AllStates, Children, Path, Source>
:
Expand Down Expand Up @@ -4058,15 +4078,22 @@ type SnapshotBuilderOptions = {
readonly prefix: string
}

type FromMethodKind = "leaf" | "nested"

const withFrom = <Method extends (value: unknown, ...args: ReadonlyArray<any>) => unknown>(
method: Method
): Method & { readonly from: (input: unknown, ...args: ReadonlyArray<any>) => unknown } => {
method: Method,
kind: FromMethodKind
): Method & { readonly from: (...args: ReadonlyArray<any>) => unknown } => {
Object.defineProperty(method, "from", {
value: (input: unknown, ...args: ReadonlyArray<any>) =>
Model.markStateConstruction(method(Model.makeStateInput(input), ...args)),
value: (...args: ReadonlyArray<any>) => {
const omitted = args.length === 0 || (kind === "nested" && args.length === 1 && typeof args[0] === "function")
const input = omitted ? {} : args[0]
const rest = omitted ? args : args.slice(1)
return Model.markStateConstruction(method(Model.makeStateInput(input), ...rest))
},
enumerable: false
})
return method as Method & { readonly from: (input: unknown, ...args: ReadonlyArray<any>) => unknown }
return method as Method & { readonly from: (...args: ReadonlyArray<any>) => unknown }
}

const makeSnapshotBuilder = (
Expand All @@ -4075,8 +4102,12 @@ const makeSnapshotBuilder = (
): unknown => {
const builder: Record<string, unknown> = {}
for (const key of Object.keys(states)) {
builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) =>
makeSnapshotForNode(states[key], key, value, selector, options)
const path = options.prefix === "" ? key : `${options.prefix}.${key}`
const node = Model.getStateNodeDefinition(path, states[key])
builder[key] = withFrom(
(value: unknown, selector?: (builder: unknown) => unknown) =>
makeSnapshotForNode(states[key], key, value, selector, options),
node.states === undefined ? "leaf" : "nested"
)
}
return builder
Expand All @@ -4100,6 +4131,8 @@ const makeParallelSnapshotBuilder = (
if (hasProperty(regions, key)) {
continue
}
const path = options.prefix === "" ? key : `${options.prefix}.${key}`
const node = Model.getStateNodeDefinition(path, states[key])
builder[key] = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => {
const nextRegions: Record<string, unknown> = {}
for (const regionKey of Object.keys(regions)) {
Expand All @@ -4108,7 +4141,7 @@ const makeParallelSnapshotBuilder = (
nextRegions[key] = makeSnapshotForNode(states[key], key, value, selector, options)
const next = makeParallelSnapshotBuilder(states, options, nextRegions)
return Model.isStateConstruction(builder) ? Model.markStateConstruction(next) : next
})
}, node.states === undefined ? "leaf" : "nested")
}
return builder
}
Expand Down Expand Up @@ -4304,7 +4337,7 @@ const makeLocalTargetChildBuilder = (
extendTargetValues(values, child.path, value),
source
))
})
}, child.type === "atomic" || child.type === "final" ? "leaf" : "nested")
}
return builder
}
Expand All @@ -4324,7 +4357,7 @@ const makeLocalTargetBuilder = (
throw new Error(`Machine expected target "${scope}" builder to provide an active child state`)
}
return selector(makeLocalTargetChildBuilder(states, stateNodes, scope, { [scope]: value }, source))
})
}, "nested")
return builder
}

Expand Down Expand Up @@ -4352,7 +4385,7 @@ const makeBranchTargetNodeBuilder = (
): unknown => {
const node = getTargetBuilderNode(stateNodes, path)
if (node.type === "atomic" || node.type === "final") {
return withFrom((value: unknown) => makeTargetWithValues(node.path, value, values))
return withFrom((value: unknown) => makeTargetWithValues(node.path, value, values), "leaf")
}
const builder = withFrom((value: unknown, selector?: (builder: unknown) => unknown) => {
if (node.type === "parallel") {
Expand Down Expand Up @@ -4386,7 +4419,7 @@ const makeBranchTargetNodeBuilder = (
source
)
return selector(nextBuilder)
}) as unknown as Record<string, unknown>
}, "nested") as unknown as Record<string, unknown>
if (node.type !== "parallel" || source === node.path || source.startsWith(`${node.path}.`)) {
addBranchTargetChildren(builder, states, stateNodes, node.path, values, source)
}
Expand Down Expand Up @@ -4445,7 +4478,7 @@ const makeTargetBuilder = <const States extends Machine.StateSchemas>(
* Machine.make({
* states: States.states,
* events: [],
* initial: () => States.initial.idle(new Idle({}))
* initial: () => States.initial.idle.from()
* })
* ```
*
Expand Down
Loading