XState version
5.31.0
Description
When a root machine uses setup() with multiple invoked child actors (each having their own internal fromPromise/fromObservable actors), accessing a specific child via snapshot.children.specificId returns a union of all actor contexts instead of the specific child's context. This makes it impossible to access child actor context without runtime type narrowing.
Related to #5181 — same root cause, different angle.
Root Cause Analysis
We traced this through the type system:
1. ToProvidedActor doesn't infer literal ids from invoke config
In setup.ts, ToProvidedActor derives actor id from TChildrenMap:
id: IsNever<TChildrenMap> extends true
? string | undefined
: K extends keyof Invert<TChildrenMap>
? Invert<TChildrenMap>[K] & string // literal id
: string | undefined; // fallback
When using setup() without explicit types.children, TChildrenMap defaults to {}, so all actors get id: string | undefined — even those with literal ids in the invoke config. The literal ids from invoke: [{ id: "auth", src: "auth" }] are not propagated into TChildrenMap.
2. ToChildren index signature includes internal actors
In types.ts, ToChildren decides whether to include an index signature:
[undefined extends TActor['id']
? 'include' // ← triggered because internal actors have id: undefined
: string extends TActor['id']
? 'include'
: 'exclude']
Internal actors (fromPromise, fromObservable) from child machines have id: undefined, which triggers the index signature. The index signature distributes over ALL non-concrete actors:
[id: string]: TActor extends any ? ActorRefFromLogic<TActor['logic']> | undefined : never;
3. TypeScript intersection pollutes concrete keys
The final type is Compute<ToConcreteChildren<TActor> & { [id: string]: UnionOfAllActorRefs }>. When accessing children.auth, TypeScript intersects the concrete mapping with the index signature, producing a union.
Reproduction
import { fromPromise, setup, createActor } from 'xstate';
const authLogic = setup({
actors: {
fetchUser: fromPromise(async () => ({ token: 'tok' }))
}
}).createMachine({
context: { token: null as string | null },
initial: 'idle',
states: { idle: {} }
});
const telemetryLogic = setup({
actors: {
checkConsent: fromPromise(async () => ({ status: 'granted' }))
}
}).createMachine({
context: { store: null as string | null },
initial: 'idle',
states: { idle: {} }
});
const root = setup({
actors: { auth: authLogic, telemetry: telemetryLogic }
}).createMachine({
context: { value: 0 },
invoke: [
{ id: 'auth', systemId: 'auth', src: 'auth' },
{ id: 'telemetry', systemId: 'telemetry', src: 'telemetry' }
]
});
const snapshot = createActor(root).getSnapshot();
// ❌ Error: Property 'token' does not exist on type
// '{ token: string | null } | { store: string | null }'
snapshot.children.auth!.getSnapshot().context.token;
// ❌ Error: Property 'store' does not exist on type
// '{ token: string | null } | { store: string | null }'
snapshot.children.telemetry!.getSnapshot().context.store;
Workaround
Manually declare types.children (from #5181 comment):
const root = setup({
types: {
children: {} as { auth: 'auth'; telemetry: 'telemetry' }
},
actors: { auth: authLogic, telemetry: telemetryLogic }
}).createMachine({ /* ... */ });
This populates TChildrenMap and gives ToProvidedActor the literal ids. But this shouldn't be necessary — the ids are already specified in the invoke config.
Partial Fix
We have a partial fix for ToChildren that prevents actors with literal ids from appearing in the index signature:
type DynamicIdActors<TActor extends ProvidedActor> = TActor extends any
? TActor['id'] extends string
? string extends TActor['id']
? TActor
: never
: never
: never;
This correctly fixes cases where types.actors or types.children are explicitly declared (existing tests improved — the index signature now only contains actors without literal ids). But it doesn't fix the setup() + invoke case because TChildrenMap inference is the upstream problem.
Full Fix
The complete solution requires two changes:
ToChildren (partial, ready): Use DynamicIdActors to exclude literal-id and undefined-id actors from the index signature
setup() / ToProvidedActor (needed): Infer TChildrenMap from invoke configs in createMachine(), so literal ids like { id: "auth", src: "auth" } propagate into the actor type system without requiring manual types.children
Expected Behaviour
snapshot.children.auth should return ActorRefFromLogic<typeof authLogic> | undefined — the specific actor type, not a union of all child actor types.
XState version
5.31.0
Description
When a root machine uses
setup()with multiple invoked child actors (each having their own internalfromPromise/fromObservableactors), accessing a specific child viasnapshot.children.specificIdreturns a union of all actor contexts instead of the specific child's context. This makes it impossible to access child actor context without runtime type narrowing.Related to #5181 — same root cause, different angle.
Root Cause Analysis
We traced this through the type system:
1.
ToProvidedActordoesn't infer literal ids frominvokeconfigIn
setup.ts,ToProvidedActorderives actoridfromTChildrenMap:When using
setup()without explicittypes.children,TChildrenMapdefaults to{}, so all actors getid: string | undefined— even those with literalids in theinvokeconfig. The literal ids frominvoke: [{ id: "auth", src: "auth" }]are not propagated intoTChildrenMap.2.
ToChildrenindex signature includes internal actorsIn
types.ts,ToChildrendecides whether to include an index signature:Internal actors (
fromPromise,fromObservable) from child machines haveid: undefined, which triggers the index signature. The index signature distributes over ALL non-concrete actors:3. TypeScript intersection pollutes concrete keys
The final type is
Compute<ToConcreteChildren<TActor> & { [id: string]: UnionOfAllActorRefs }>. When accessingchildren.auth, TypeScript intersects the concrete mapping with the index signature, producing a union.Reproduction
Workaround
Manually declare
types.children(from #5181 comment):This populates
TChildrenMapand givesToProvidedActorthe literal ids. But this shouldn't be necessary — the ids are already specified in theinvokeconfig.Partial Fix
We have a partial fix for
ToChildrenthat prevents actors with literal ids from appearing in the index signature:This correctly fixes cases where
types.actorsortypes.childrenare explicitly declared (existing tests improved — the index signature now only contains actors without literal ids). But it doesn't fix thesetup()+invokecase becauseTChildrenMapinference is the upstream problem.Full Fix
The complete solution requires two changes:
ToChildren(partial, ready): UseDynamicIdActorsto exclude literal-id and undefined-id actors from the index signaturesetup()/ToProvidedActor(needed): InferTChildrenMapfrominvokeconfigs increateMachine(), so literal ids like{ id: "auth", src: "auth" }propagate into the actor type system without requiring manualtypes.childrenExpected Behaviour
snapshot.children.authshould returnActorRefFromLogic<typeof authLogic> | undefined— the specific actor type, not a union of all child actor types.