Skip to content

Children map union pollution from internal actors when using setup() with invoke arrays #5515

@joshuaellis

Description

@joshuaellis

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:

  1. ToChildren (partial, ready): Use DynamicIdActors to exclude literal-id and undefined-id actors from the index signature
  2. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions