Tipar los actores: invoke y spawn de punta a punta
Declarar los actor logics en setup cierra el último tramo del contrato: el que va desde el dato que la máquina padre entrega al hijo hasta el valor que el hijo devuelve al terminar. Esta lección recorre ese camino completo. Cómo anotar el input de un fromPromise y por qué esa anotación viaja hasta la función que calcula el input en el invoke; cómo el output del actor determina el tipo de event.output en el onDone sin declarar ningún evento a mano; por qué el error del onError es unknown y qué hacer con esa honestidad incómoda en lugar de castearla; cómo types.children convierte snapshot.children en un objeto con claves conocidas; y cómo spawn dentro de assign devuelve una referencia tipada con la que hablar con el hijo. El resultado es una jerarquía de actores donde ningún tramo del contrato queda librado a la memoria.
Un actor invocado es una promesa que la máquina hace en dos direcciones a la vez: promete entregarle un dato de entrada con cierta forma y promete saber recibir un resultado con cierta otra. En la v4 ninguna de las dos mitades era verificable, y por eso los onDone de aquella época están llenos de accesos a campos que nadie garantizaba y de castings puestos para que el editor callara. Declarar los actor logics en setup cierra ese hueco, y lo hace con una propiedad que conviene subrayar: los tipos no se declaran dos veces. Anotas el input en la función que implementa el actor, escribes su cuerpo con normalidad, y XState deduce del propio código qué debe recibir el invoke y qué tipo tendrá el resultado en el onDone. La única declaración que sigue siendo tuya es la del actor; el resto del contrato se propaga solo, y esta lección sigue esa propagación tramo a tramo hasta el punto exacto donde se detiene por razones de fondo: el error.
- Declarar actor logics en
setupy anotar suinputpara que el contrato de entrada quede fijado. - Leer cómo el tipo de retorno del actor determina
event.outputen elonDonesin declarar eventos. - Entender por qué el
errordelonErroresunknowny tratarlo con estrechamiento en vez de casting. - Tipar actores dinámicos con
types.children,spawndentro deassigny las referencias que devuelve.
El contrato de entrada: anotar el input una sola vez
Un actor logic es una definición reutilizable: una promesa, un callback, un observable, una función de transición u otra máquina. Lo que lo hace tipable es que su entrada se anota en el sitio donde se escribe la implementación, y desde ahí viaja al resto del sistema.
import { setup, assign, fromPromise } from 'xstate'
type Perfil = { id: string; nombre: string; plan: 'libre' | 'pro' }
const cargarPerfil = fromPromise(
// la anotacion del input es la unica declaracion manual del contrato
async ({ input }: { input: { usuarioId: string; incluirPlan: boolean } }): Promise<Perfil> => {
const r = await fetch(`/api/perfil/${input.usuarioId}?plan=${input.incluirPlan}`)
if (!r.ok) throw new Error('perfil no disponible')
return r.json() as Promise<Perfil>
},
)
const sesion = setup({
types: {
context: {} as { usuarioId: string; perfil: Perfil | null; error: string | null },
events: {} as { type: 'CARGAR' } | { type: 'REINTENTAR' },
},
actors: { cargarPerfil },
}).createMachine({
context: { usuarioId: 'u-1', perfil: null, error: null },
initial: 'inactivo',
states: {
inactivo: { on: { CARGAR: 'cargando' } },
cargando: {
invoke: {
src: 'cargarPerfil',
// el input DEBE encajar con la anotacion: falta un campo y no compila
input: ({ context }) => ({ usuarioId: context.usuarioId, incluirPlan: true }),
onDone: {
target: 'listo',
// event.output es Perfil, deducido del tipo de retorno del actor
actions: assign({ perfil: ({ event }) => event.output }),
},
onError: 'fallo',
},
},
listo: {},
fallo: { on: { REINTENTAR: 'cargando' } },
},
})
Sigue el recorrido de un tipo por esta máquina. La anotación del input obliga a que la función del invoke devuelva justo esos campos; olvidar incluirPlan es un error de compilación en la línea del invoke, no un undefined que aparecerá tres capas más abajo. El tipo de retorno determina que event.output sea Perfil, y como el assign está escrito en línea, ese evento llega estrechado al evento de terminación de este actor concreto. En ningún punto has escrito el tipo del evento onDone: no existe tal declaración porque no hace falta.
Una máquina es un actor logic más, así que se registra en actors como cualquier otro. Su input es el que declares en su propio types.input, y lo que su onDone entrega es su types.output, producido por la propiedad output del estado final. La consecuencia práctica es notable: una jerarquía de submáquinas queda verificada en todas sus juntas, y refactorizar el output de una hija enciende inmediatamente el onDone de todas las madres que la invocan. El contrato entre padre e hijo deja de ser una convención documentada en un comentario y pasa a ser una obligación que el compilador recuerda por ti.
El error es unknown, y esa es la respuesta correcta
Hay un tramo del contrato que XState se niega a fingir que conoce. En onError, el evento trae una propiedad error de tipo unknown, y no por dejadez: en JavaScript se puede lanzar cualquier cosa —un Error, una cadena, un objeto de respuesta, un undefined—, y ninguna anotación puede garantizar qué llegará. Prometer un tipo ahí sería mentir.
function mensajeDeError(e: unknown): string {
if (e instanceof Error) return e.message
if (typeof e === 'string') return e
return 'error desconocido'
}
// en el onError, se estrecha en vez de castear
onError: {
target: 'fallo',
actions: assign({ error: ({ event }) => mensajeDeError(event.error) }),
}
La tentación de escribir event.error as Error es fuerte y conviene resistirla con un argumento concreto: el día que una biblioteca de red rechace con un objeto de respuesta en lugar de con un Error, el casting hará que .message sea undefined y el mensaje de fallo que verá el usuario será la cadena vacía, sin que nada haya explotado. Una función de estrechamiento como la anterior cuesta cuatro líneas, se escribe una vez por proyecto y convierte un fallo silencioso en un mensaje correcto.
flowchart LR C[context y evento] -->|input| A[actor logic declarado en setup] A -->|resuelve| D[onDone con event.output tipado] A -->|rechaza| E[onError con error unknown] E --> N[estrechar con una funcion de guardia] D --> X[assign al context] style A fill:#cba6f7,color:#11111b style D fill:#a6e3a1,color:#11111b style E fill:#f38ba8,color:#11111b
Actores dinámicos: children tipados y referencias vivas
invoke cubre el actor atado a un estado, pero a veces necesitas actores que nacen y mueren según los datos: uno por cada fichero que se sube, uno por cada pestaña abierta. Para eso está spawn, disponible dentro de assign, y como el nombre del actor está declarado en setup, la referencia que devuelve viene tipada.
import { setup, assign, stopChild, type ActorRefFrom } from 'xstate'
const subida = setup({
types: {
context: {} as { tareas: Record<string, ActorRefFrom<typeof subirFichero>> },
events: {} as { type: 'SUBIR'; fichero: File } | { type: 'CANCELAR'; id: string },
// declara que hijo corresponde a que logic, para tipar snapshot.children
children: {} as { auditor: 'auditoria' },
},
// subirFichero y auditoria son actor logics definidos aparte
actors: { subirFichero, auditoria },
actions: {
lanzarSubida: assign({
tareas: ({ context, spawn }, params: { fichero: File; id: string }) => ({
...context.tareas,
// spawn conoce el input exigido por subirFichero
[params.id]: spawn('subirFichero', { id: params.id, input: { fichero: params.fichero } }),
}),
}),
},
}).createMachine({
context: { tareas: {} },
invoke: { src: 'auditoria', id: 'auditor' },
on: {
SUBIR: {
actions: {
type: 'lanzarSubida',
params: ({ event }) => ({ fichero: event.fichero, id: crypto.randomUUID() }),
},
},
CANCELAR: { actions: stopChild(({ context, event }) => context.tareas[event.id]) },
},
})
Dos piezas de este ejemplo merecen atención. La primera es ActorRefFrom, el ayudante que extrae de un actor logic el tipo de su referencia, y que permite guardar hijos en el context sin recurrir a un tipo amplio; junto a él viven SnapshotFrom, InputFrom y OutputFrom, que hacen lo propio con la snapshot, la entrada y la salida. La segunda es types.children, que declara qué identificador de hijo corresponde a qué actor logic y convierte snapshot.children.auditor en una referencia con tipo conocido en lugar de en una consulta a ciegas por una cadena cualquiera.
El actor invocado por invoke se detiene solo cuando se sale del estado que lo declaró; el generado con spawn no. Si lo guardas en el context, tú decides cuándo termina, y olvidarlo produce actores vivos suscritos a cosas que ya nadie mira: el equivalente exacto de una fuga de memoria, con la agravante de que sigue procesando eventos. Detén cada hijo dinámico con stopChild cuando su trabajo acabe y quítalo del context en el mismo assign. El sistema de tipos te acompaña hasta la puerta —te garantiza que la referencia es del tipo correcto— pero no puede recordarte que hay que cerrarla.
Merece la pena entender por qué el tramo de los actores es el que más rendimiento da de todo el nivel, y la razón no es técnica sino estadística: los errores no se concentran en el interior de los componentes, se concentran en sus juntas. Dentro de una función el compilador siempre supo lo que pasaba; lo que nunca supo es qué llega desde el otro lado de una frontera —de la red, del disco, de otro proceso, de otra máquina—, y precisamente ahí es donde vive un actor. Declararlo en setup no mejora el código del actor, que ya estaba tipado por sí mismo: mejora las dos costuras que lo unen al resto, la de entrada y la de salida, que son justamente los puntos donde el desacuerdo entre lo que una parte cree enviar y lo que la otra cree recibir se manifiesta en producción y no en el editor. Por eso la mecánica de esta lección es la que es. El input se anota una vez y se verifica en cada invocación; el output no se declara en absoluto y sin embargo llega tipado al onDone, porque el propio código del actor ya contenía esa verdad y lo único que hacía falta era propagarla en vez de volver a escribirla. Y por eso el error es unknown, que es la lección más profunda del conjunto: un sistema de tipos honesto no adorna las fronteras con promesas que no puede sostener, sino que te señala exactamente dónde el mundo exterior es impredecible y te obliga a decidir qué haces al respecto. Ese unknown no es una carencia de XState; es un aviso que dice que del otro lado hay un mundo que no controlas. Aceptarlo y estrecharlo es tipar de verdad. Castearlo es escribir la misma aplicación no tipada de siempre, esta vez con la falsa tranquilidad de un compilador al que has convencido de que mire hacia otro lado.
- Toma un
invokeque hoy reciba datos por elcontextsininputexplícito y conviértelo a un actor coninputanotado. Comprueba qué error aparece si omites un campo al calcularlo. - Elimina todos los castings de tus
onDoney verifica queevent.outputya tiene el tipo correcto sin ayuda. Si alguno sigue haciendo falta, averigua qué declaración falta. - Escribe una función de estrechamiento para el
errordelonErrory úsala en todos tus manejadores de fallo. Prueba a rechazar con una cadena y comprueba que el mensaje sigue siendo correcto. - Invoca una submáquina y declara su
outputen el estado final. Cambia después la forma de eseoutputy anota cuántos sitios del padre se encienden a la vez. - Sustituye un
invokefijo porspawndinámico guardando las referencias en elcontextconActorRefFrom, y añade elstopChildcorrespondiente en la ruta de cancelación. - Declara
types.childrenpara un hijo invocado y accede a él desde la snapshot. Compara la experiencia con la de buscarlo por una cadena sin declarar.