Skip to content

Commit 105096e

Browse files
fix: infer dynamic invoke effect channels
1 parent 0b28408 commit 105096e

14 files changed

Lines changed: 520 additions & 71 deletions

File tree

.changeset/inline-invocation-lifecycles.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@
55
Replace `Machine.invokeEffect`, `Machine.after`, `Machine.invokeMachine`, and `Machine.effect` with one inline `invoke` lifecycle object API and a zero-runtime `Machine.invoke` inference helper.
66

77
Choose an `effect`, `after`, `logic`, or `child` source and handle typed outcomes directly with `onDone`, `onFailure`, and `onSnapshot`. Lifecycle handlers can now transition the owning state without routing results through mapped machine events.
8+
9+
State-dependent Effect sources infer their owner state, output, error, and service requirements together without a manual return annotation.

README.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,22 @@ Waiting: {
221221
Use `effect` for one Effect, `after` for a cancellable delay, `logic` for a
222222
reusable process, and `child` for a complete child statechart—all through
223223
`Machine.invoke({...})`. The helper is an identity at runtime and preserves
224-
source-channel inference across lifecycle handlers. A direct `invoke: { ... }`
225-
object is also supported and supplies owner context to dynamic `effect`,
226-
`logic`, `after`, and child `input` functions when their lifecycle handlers do
227-
not consume typed context. When handlers need typed output or failure values,
228-
use `Machine.invoke` and annotate a dynamic source's return type. Reuse one
229-
exported `Machine.child(id, machine)` descriptor for invocation, `sendTo`, and
230-
child lookup.
224+
owner-context and source-channel inference across lifecycle handlers, including
225+
for state-dependent Effects:
226+
227+
```ts
228+
invoke: Machine.invoke({
229+
id: "load-document",
230+
effect: ({ state }) => loadDocument(state.documentId),
231+
onDone: ({ output, target }) => target.full.Ready({ document: output }),
232+
onFailure: ({ error, target }) => target.full.Failed({ message: error.message })
233+
})
234+
```
235+
236+
A direct `invoke: { ... }` object is also supported when its lifecycle handlers
237+
do not need source-derived context. Reuse one exported
238+
`Machine.child(id, machine)` descriptor for invocation, `sendTo`, and child
239+
lookup.
231240

232241
`onDone` is required for a non-`never` output, and `onFailure` is required for a
233242
non-`never` typed error; each handler is omitted when its channel is `never`.

docs/agent-guide.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,12 @@ its extra control is required:
102102
`AtomMachine.resume(machine, snapshot)` for service-free machines.
103103
- Use one invocation object: `effect` for one-shot work, `after` for a timer,
104104
`logic` for reusable process logic, and `child` for a complete child
105-
statechart. `Machine.invoke({...})` preserves source channels across sibling
106-
lifecycle handlers. Use a direct object when a dynamic source or child input
107-
primarily needs contextual owner-state inference and its lifecycle handlers
108-
do not consume typed context.
105+
statechart. `Machine.invoke({...})` preserves owner context and source
106+
channels across sibling lifecycle handlers. Use a direct object only when its
107+
lifecycle handlers do not need source-derived context.
109108
- Use `Machine.child(id, machine)` for a complete statechart descriptor and
110-
`Machine.childAddress<Event>(id)` for a low-level process address. An
111-
logic invocation is addressable only when `Machine.invoke` receives that
109+
`Machine.childAddress<Event>(id)` for a low-level process address. A logic
110+
invocation is addressable only when `Machine.invoke` receives that
112111
address explicitly.
113112
- Use the callback's `enqueue` argument for `raise`, `emit`, `sendTo`, and
114113
`stop`. These operations record closed actor commands and do not run Effects.
@@ -582,9 +581,20 @@ errors, defects, and interruption are machine failures rather than a second
582581
phase in `onFailure`.
583582
584583
When a source function reads `state`, `parent`, `parents`, or the entry `event`,
585-
the direct object form supplies its owner context. If sibling handlers also
586-
need typed lifecycle context, wrap the object with `Machine.invoke` and annotate
587-
the returned Effect or Logic type.
584+
`Machine.invoke` infers that owner context and the returned Effect's output,
585+
error, and service channels together. No return annotation is needed:
586+
587+
```ts
588+
invoke: Machine.invoke({
589+
id: "load",
590+
effect: ({ state }) => LoadService.load(state.userId),
591+
onDone: ({ output, target }) => target.full.Loaded({ user: output }),
592+
onFailure: ({ error, target }) => target.full.LoadFailed({ error })
593+
})
594+
```
595+
596+
A direct `invoke: { ... }` object remains available when lifecycle handlers do
597+
not need source-derived context.
588598
589599
A cancellable timer uses the same object:
590600

examples/playground/src/examples/media-player/machine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const MediaPlayerMachine = MediaPlayerDefinition.handle({
3333
Loading: {
3434
invoke: Machine.invoke({
3535
id: "load-audio",
36-
effect: ({ state }): ReturnType<typeof loadAudio> => loadAudio(state.url),
36+
effect: ({ state }) => loadAudio(state.url),
3737
onDone: (_, enqueue) => enqueue.raise(MediaPlayerInternalEvents.LoadSucceeded()),
3838
onFailure: ({ error }, enqueue) =>
3939
enqueue.raise(MediaPlayerInternalEvents.OperationFailed({ message: error.message }))

examples/pokemon/src/machines/selection.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,7 @@ export const SelectionMachine = Machine.make({
106106
Searching: {
107107
invoke: Machine.invoke({
108108
id: "search",
109-
effect: ({ parents }): ReturnType<typeof searchPokemon> =>
110-
searchPokemon(parents["form.search"].searchText),
109+
effect: ({ parents }) => searchPokemon(parents["form.search"].searchText),
111110
onDone: ({ output, target }) =>
112111
output.result.pipe(
113112
Option.match({
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { Effect, Schema } from "effect"
3+
4+
export class LoadError {
5+
readonly _tag = "LoadError"
6+
}
7+
8+
export const Loading = Schema.TaggedStruct("Loading", { userId: Schema.String })
9+
10+
export const States = Machine.defineStates({ Loading })
11+
12+
export const loadUser = (userId: string) => Effect.fail(new LoadError()).pipe(Effect.as({ id: userId, name: "Ada" }))
13+
14+
export const machine = Machine.make({
15+
states: States.states,
16+
events: [],
17+
initial: () => States.initial.Loading(Loading.make({ userId: "user-1" }))
18+
})

perf/types/dynamic-invoke.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Machine } from "@typeonce/effect-machine"
2+
import { LoadError, loadUser, machine } from "./dynamic-invoke-control.js"
3+
4+
interface User {
5+
readonly id: string
6+
readonly name: string
7+
}
8+
9+
const invoked = machine.handle({
10+
Loading: {
11+
invoke: Machine.invoke({
12+
id: "load-user",
13+
effect: ({ state }) => loadUser(state.userId),
14+
onDone: ({ output }) => {
15+
const user: User = output
16+
void user
17+
},
18+
onFailure: ({ error }) => {
19+
const loadError: LoadError = error
20+
void loadError
21+
}
22+
})
23+
}
24+
})
25+
26+
void invoked

scripts/type-performance.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ const scenarios = [
7474
maxInstantiations: 30_000,
7575
maxMarginalInstantiations: 19_000
7676
},
77+
{
78+
id: "dynamic-invoke-control",
79+
label: "dynamic Machine.invoke control",
80+
file: "dynamic-invoke-control.ts",
81+
hidden: true
82+
},
83+
{
84+
id: "dynamic-invoke",
85+
label: "Machine.invoke (state-dependent Effect)",
86+
file: "dynamic-invoke.ts",
87+
control: "dynamic-invoke-control",
88+
maxInstantiations: 85_000,
89+
maxMarginalInstantiations: 76_000
90+
},
7791
{
7892
id: "handle-depth-24-control",
7993
label: "machine.handle depth 24 control",

src/Machine.ts

Lines changed: 152 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1851,7 +1851,7 @@ export interface ChildMachine<Id extends string, M extends Machine.Any> {
18511851
readonly machine: M
18521852

18531853
/** @internal */
1854-
readonly [ChildMachineLogicTypeId]: (input: unknown) => Logic<any, any, any, any, any, any>
1854+
readonly [ChildMachineLogicTypeId]: (input?: unknown) => Logic<any, any, any, any, any, any>
18551855
}
18561856

18571857
/**
@@ -4480,12 +4480,22 @@ export declare namespace Machine {
44804480
readonly onSnapshot?: never
44814481
}
44824482
& InvokeDoneRequirement<
4483-
Effect.Success<Fx>,
4484-
InvokeTransition<States, Events, Emits, InvokeDoneContext<States, Events, Emits, StateId, Effect.Success<Fx>>>
4483+
Effect.Success<NoInfer<Fx>>,
4484+
InvokeTransition<
4485+
States,
4486+
Events,
4487+
Emits,
4488+
InvokeDoneContext<States, Events, Emits, StateId, Effect.Success<NoInfer<Fx>>>
4489+
>
44854490
>
44864491
& InvokeFailureRequirement<
4487-
Effect.Error<Fx>,
4488-
InvokeTransition<States, Events, Emits, InvokeFailureContext<States, Events, Emits, StateId, Effect.Error<Fx>>>
4492+
Effect.Error<NoInfer<Fx>>,
4493+
InvokeTransition<
4494+
States,
4495+
Events,
4496+
Emits,
4497+
InvokeFailureContext<States, Events, Emits, StateId, Effect.Error<NoInfer<Fx>>>
4498+
>
44894499
>
44904500

44914501
export type TimerInvokeArgs<
@@ -6253,6 +6263,67 @@ export const decodeSnapshot: <
62536263
Machine.SnapshotDecodingServices<States>
62546264
> = internal.decodeSnapshot
62556265

6266+
type DynamicEffectInvokeSource<
6267+
States extends Machine.StateSchemas,
6268+
Events extends ReadonlyArray<Machine.TaggedSchema>,
6269+
Emits extends ReadonlyArray<Machine.TaggedSchema>,
6270+
StateId extends Machine.StateIdentifier<States>,
6271+
Source extends (
6272+
context: Machine.InvokeContext<States, Events, Emits, StateId>
6273+
) => Effect.Effect<unknown, unknown, unknown>
6274+
> = {
6275+
readonly id: InvokeLifecycleId
6276+
readonly effect: Source
6277+
readonly after?: never
6278+
readonly logic?: never
6279+
readonly child?: never
6280+
readonly address?: never
6281+
readonly onSnapshot?: never
6282+
}
6283+
6284+
type DynamicEffectDoneHandler<
6285+
States extends Machine.StateSchemas,
6286+
Events extends ReadonlyArray<Machine.TaggedSchema>,
6287+
Emits extends ReadonlyArray<Machine.TaggedSchema>,
6288+
StateId extends Machine.StateIdentifier<States>,
6289+
Source extends (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
6290+
> = Machine.InvokeTransition<
6291+
States,
6292+
Events,
6293+
Emits,
6294+
Machine.InvokeDoneContext<States, Events, Emits, StateId, Effect.Success<ReturnType<NoInfer<Source>>>>
6295+
>
6296+
6297+
type DynamicEffectFailureHandler<
6298+
States extends Machine.StateSchemas,
6299+
Events extends ReadonlyArray<Machine.TaggedSchema>,
6300+
Emits extends ReadonlyArray<Machine.TaggedSchema>,
6301+
StateId extends Machine.StateIdentifier<States>,
6302+
Source extends (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
6303+
> = Machine.InvokeTransition<
6304+
States,
6305+
Events,
6306+
Emits,
6307+
Machine.InvokeFailureContext<States, Events, Emits, StateId, Effect.Error<ReturnType<NoInfer<Source>>>>
6308+
>
6309+
6310+
type DynamicEffectInvokeResult<
6311+
States extends Machine.StateSchemas,
6312+
Events extends ReadonlyArray<Machine.TaggedSchema>,
6313+
Emits extends ReadonlyArray<Machine.TaggedSchema>,
6314+
StateId extends Machine.StateIdentifier<States>,
6315+
Source extends (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
6316+
> =
6317+
& Machine.InvokeConfig<States, Events, Emits, StateId>
6318+
& Machine.InvokeTyped<
6319+
Effect.Success<ReturnType<Source>>,
6320+
Effect.Error<ReturnType<Source>>,
6321+
Effect.Services<ReturnType<Source>>,
6322+
never
6323+
>
6324+
6325+
type InvokeChannelIsNever<Value> = IsAny<Value> extends true ? false : [Value] extends [never] ? true : false
6326+
62566327
/**
62576328
* Preserves inference for a state-owned invocation configuration.
62586329
*
@@ -6263,11 +6334,9 @@ export const decodeSnapshot: <
62636334
*
62646335
* This constructor is an identity at runtime, but preserves lifecycle callback
62656336
* inference through published declarations. Effects and durations may be
6266-
* supplied directly or derived from the owning state's entry context. A direct
6267-
* object in the state handler gives function-valued sources and child inputs
6268-
* owner-context inference when lifecycle handlers do not consume typed context.
6269-
* Use this constructor and annotate a dynamic source's return type when sibling
6270-
* handlers need typed output or error values. Logic invocations require both a
6337+
* supplied directly or derived from the owning state's entry context. Dynamic
6338+
* Effect sources infer the owner context, output, error, and service channels
6339+
* together without a return annotation. Logic invocations require both a
62716340
* lifecycle `id` and a typed communication `address`. Child descriptors already
62726341
* own their identity, so `id` and `address` must not be repeated.
62736342
*
@@ -6292,28 +6361,80 @@ export const invoke: {
62926361
const Events extends ReadonlyArray<Machine.TaggedSchema>,
62936362
const Emits extends ReadonlyArray<Machine.TaggedSchema>,
62946363
StateId extends Machine.StateIdentifier<States>,
6295-
Output,
6296-
Error,
6297-
Requirements
6364+
const Source extends (
6365+
context: Machine.InvokeContext<States, Events, Emits, StateId>
6366+
) => Effect.Effect<unknown, unknown, unknown>
62986367
>(
6299-
config: Machine.EffectInvokeArgs<
6300-
States,
6301-
Events,
6302-
Emits,
6303-
StateId,
6304-
Effect.Effect<Output, Error, Requirements>,
6305-
(
6306-
context: Machine.InvokeContext<States, Events, Emits, StateId>
6307-
) => Effect.Effect<Output, Error, Requirements>
6308-
>
6309-
):
6310-
& Machine.InvokeConfig<States, Events, Emits, StateId>
6311-
& Machine.InvokeTyped<
6312-
Output,
6313-
Error,
6314-
Requirements,
6315-
never
6316-
>
6368+
config:
6369+
& DynamicEffectInvokeSource<States, Events, Emits, StateId, Source>
6370+
& {
6371+
readonly onDone: DynamicEffectDoneHandler<States, Events, Emits, StateId, Source>
6372+
readonly onFailure: DynamicEffectFailureHandler<States, Events, Emits, StateId, Source>
6373+
},
6374+
..._validation: InvokeChannelIsNever<Effect.Success<ReturnType<Source>>> extends true ? [
6375+
"onDone must be omitted when the Effect output is never"
6376+
]
6377+
: InvokeChannelIsNever<Effect.Error<ReturnType<Source>>> extends true ? [
6378+
"onFailure must be omitted when the Effect error is never"
6379+
]
6380+
: []
6381+
): DynamicEffectInvokeResult<States, Events, Emits, StateId, Source>
6382+
<
6383+
const States extends Machine.StateSchemas,
6384+
const Events extends ReadonlyArray<Machine.TaggedSchema>,
6385+
const Emits extends ReadonlyArray<Machine.TaggedSchema>,
6386+
StateId extends Machine.StateIdentifier<States>,
6387+
const Source extends (
6388+
context: Machine.InvokeContext<States, Events, Emits, StateId>
6389+
) => Effect.Effect<unknown, never, unknown>
6390+
>(
6391+
config:
6392+
& DynamicEffectInvokeSource<States, Events, Emits, StateId, Source>
6393+
& {
6394+
readonly onDone: DynamicEffectDoneHandler<States, Events, Emits, StateId, Source>
6395+
readonly onFailure?: never
6396+
},
6397+
..._validation: InvokeChannelIsNever<Effect.Success<ReturnType<Source>>> extends true ? [
6398+
"onDone must be omitted when the Effect output is never"
6399+
]
6400+
: []
6401+
): DynamicEffectInvokeResult<States, Events, Emits, StateId, Source>
6402+
<
6403+
const States extends Machine.StateSchemas,
6404+
const Events extends ReadonlyArray<Machine.TaggedSchema>,
6405+
const Emits extends ReadonlyArray<Machine.TaggedSchema>,
6406+
StateId extends Machine.StateIdentifier<States>,
6407+
const Source extends (
6408+
context: Machine.InvokeContext<States, Events, Emits, StateId>
6409+
) => Effect.Effect<never, unknown, unknown>
6410+
>(
6411+
config:
6412+
& DynamicEffectInvokeSource<States, Events, Emits, StateId, Source>
6413+
& {
6414+
readonly onDone?: never
6415+
readonly onFailure: DynamicEffectFailureHandler<States, Events, Emits, StateId, Source>
6416+
},
6417+
..._validation: InvokeChannelIsNever<Effect.Error<ReturnType<Source>>> extends true ? [
6418+
"onFailure must be omitted when the Effect error is never"
6419+
]
6420+
: []
6421+
): DynamicEffectInvokeResult<States, Events, Emits, StateId, Source>
6422+
<
6423+
const States extends Machine.StateSchemas,
6424+
const Events extends ReadonlyArray<Machine.TaggedSchema>,
6425+
const Emits extends ReadonlyArray<Machine.TaggedSchema>,
6426+
StateId extends Machine.StateIdentifier<States>,
6427+
const Source extends (
6428+
context: Machine.InvokeContext<States, Events, Emits, StateId>
6429+
) => Effect.Effect<never, never, unknown>
6430+
>(
6431+
config:
6432+
& DynamicEffectInvokeSource<States, Events, Emits, StateId, Source>
6433+
& {
6434+
readonly onDone?: never
6435+
readonly onFailure?: never
6436+
}
6437+
): DynamicEffectInvokeResult<States, Events, Emits, StateId, Source>
63176438
<
63186439
const States extends Machine.StateSchemas,
63196440
const Events extends ReadonlyArray<Machine.TaggedSchema>,

0 commit comments

Comments
 (0)