Tipar el context y los eventos: el fin del as any
El casillero types de setup es la pieza que convierte una máquina en código verificable. Esta lección diseca el idioma que lo hace posible —el valor vacío casteado que actúa como portador de tipo—, recorre las claves disponibles (context, events, input, output, tags, emitted) y establece la disciplina que las hace útiles: el context como registro plano de datos cuantitativos y los eventos como unión discriminada por type. Después aborda la asimetría que casi nadie explica: las implementaciones anónimas escritas en una transición reciben el evento estrechado, mientras que las nombradas en setup reciben la unión completa, porque setup se evalúa antes de que exista la máquina. De esa asimetría nace el último reducto del as any, y entenderla es la única forma de eliminarlo sin engañar al compilador.
Cada as any que aparece dentro de una máquina de estados es un informe de avería: alguien necesitaba una información que el compilador no tenía y decidió silenciarlo en lugar de dársela. En la v4 ese informe era casi inevitable, porque la máquina se escribía antes de que sus tipos existieran. En la v5 ya no lo es, y por eso conviene tratarlo como lo que es: la señal de que algo no se declaró en types, o de que se declaró mal, o de que se está pidiendo a una implementación nombrada un conocimiento que estructuralmente no puede tener. Esta lección recorre las tres causas. Verás qué es exactamente ese {} as Tipo que se repite en todos los ejemplos y por qué no es un truco sucio sino la única forma que TypeScript ofrece de pasar un tipo por un canal de valores; verás qué disciplina hace que el context y los eventos se dejen tipar sin pelea; y verás por qué el estrechamiento del evento funciona en unos sitios y no en otros, que es la parte incómoda y la que de verdad separa a quien usa la v5 de quien la entiende.
- Explicar el idioma
{} as Tipocomo portador de tipo y por quétypesdesaparece en tiempo de ejecución. - Declarar
context,events,input,outputytags, y saber qué aporta cada clave. - Aplicar la disciplina de unión discriminada por
typeen los eventos y de registro plano en elcontext. - Distinguir dónde el evento llega estrechado y dónde llega como unión completa, y actuar en consecuencia.
types no es configuración: es una anotación disfrazada de valor
TypeScript no ofrece ninguna sintaxis para pasar un tipo donde se espera un valor. XState resuelve esa carencia con un portador: un objeto vacío al que se le impone un tipo mediante as. El valor no importa —nunca se lee— y el bloque types entero se descarta al ejecutar; lo único que sobrevive es la información que el compilador extrajo de él.
import { setup, assign } from 'xstate'
type Contexto = {
consulta: string
resultados: string[]
intentos: number
error: string | null
}
type Evento =
| { type: 'BUSCAR'; consulta: string }
| { type: 'CANCELAR' }
| { type: 'REINTENTAR' }
const buscador = setup({
types: {
context: {} as Contexto, // portador: el objeto vacio nunca se lee
events: {} as Evento,
input: {} as { consultaInicial: string },
tags: {} as 'ocupado' | 'cancelable',
},
}).createMachine({
// el context real se construye aqui, y debe encajar con lo declarado
context: ({ input }) => ({
consulta: input.consultaInicial,
resultados: [],
intentos: 0,
error: null,
}),
initial: 'inactivo',
states: {
inactivo: { on: { BUSCAR: 'buscando' } },
buscando: { tags: ['ocupado', 'cancelable'], on: { CANCELAR: 'inactivo' } },
},
})
Observa la división del trabajo: types.context declara la FORMA, y el context de la máquina aporta el VALOR inicial, posiblemente calculado a partir del input. Son dos cosas distintas y por eso viven en sitios distintos; declarar la forma en setup es lo que permite que el compilador verifique el valor cuando aparece.
Clave de types |
Para qué sirve | Ejemplo de anotación |
|---|---|---|
context |
Forma del estado extendido | {} as { intentos: number } |
events |
Unión de todo lo que la máquina acepta | {} as { type: 'BUSCAR' } | { type: 'CANCELAR' } |
input |
Datos de creación del actor | {} as { usuarioId: string } |
output |
Valor que produce al terminar | {} as { total: number } |
tags |
Etiquetas transversales de estado | {} as 'ocupado' | 'cancelable' |
emitted |
Eventos que la máquina publica hacia fuera | {} as { type: 'guardado' } |
Declarar tags en types convierte una comprobación frágil en una consulta segura. En vez de preguntar en la vista si el estado es buscando o reintentando o validando —una lista que se queda obsoleta cada vez que añades un estado— preguntas snapshot.hasTag('ocupado'), y el compilador te impide escribir una etiqueta que no exista. La máquina decide qué estados son ocupados; la UI solo pregunta. Es la misma separación entre QUE y COMO que estructura todo el track, aplicada al vocabulario que la interfaz necesita.
Registro plano y unión discriminada: la disciplina que se deja tipar
Dos reglas hacen que el tipado fluya, y ambas son consecuencia de la teoría, no del capricho de una librería.
La primera afecta al context: manténlo como un registro plano de datos cuantitativos. Si te sorprendes escribiendo una unión discriminada DENTRO del context —algo como un campo estado con variantes que llevan datos asociados—, has descubierto estados finitos y los estás guardando en el sitio equivocado. Lo finito va en el valor de estado, que XState ya modela y estrecha; lo cuantitativo va en el context. Insistir en la unión dentro del context te obliga además a pelear con assign, que actualiza campo a campo y no sabe reconstruir una variante entera.
La segunda afecta a los eventos: una unión discriminada por la propiedad type, con type siempre literal. Es la condición que permite a TypeScript estrechar, y también la que permite a XState enrutar. Cada evento lleva consigo exactamente los datos que necesita y ninguno más; un evento con campos opcionales para tapar varios casos es una unión mal factorizada.
// MAL: un solo evento que finge ser varios
type EventoMalo = { type: 'RESPUESTA'; datos?: string[]; error?: string }
// BIEN: cada caso, su variante, con exactamente sus datos
type EventoBueno =
| { type: 'RESPUESTA_OK'; datos: string[] }
| { type: 'RESPUESTA_ERROR'; error: string }
Con la versión mala, cualquier consumidor debe comprobar en tiempo de ejecución si hay datos o si hay error, y ambos campos son opcionales a la vez, de modo que el tipo admite el estado imposible de tener los dos o ninguno. Con la buena, comprobar event.type basta y el compilador garantiza el resto.
flowchart TD
A[types.events: union discriminada] --> B{donde se usa}
B -->|implementacion en linea| C[evento estrechado al de la transicion]
B -->|implementacion nombrada en setup| D[union completa de eventos]
D --> E[params: pasar solo lo que hace falta]
style A fill:#cba6f7,color:#11111b
style C fill:#a6e3a1,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#89b4fa,color:#11111bLa asimetría del estrechamiento y el último as any
Aquí está el punto que casi ninguna introducción menciona. Una implementación escrita EN LÍNEA dentro de una transición sabe qué evento la dispara, porque el compilador la está comprobando en ese contexto: recibe el evento ya estrechado a esa variante. Una implementación NOMBRADA en setup no puede saberlo, y la razón es la misma que justificaba todo el diseño de la lección anterior: setup se evalúa antes de que la máquina exista, así que en el momento de escribirla nadie sabe todavía desde qué transiciones se la va a referenciar. Recibe, por tanto, la unión completa.
const maquina = setup({
types: { context: {} as Contexto, events: {} as Evento },
actions: {
// NOMBRADA: event es la union completa, aunque solo se use tras BUSCAR
guardaConsulta: assign({
consulta: ({ event }) =>
event.type === 'BUSCAR' ? event.consulta : '', // hay que comprobar
}),
},
}).createMachine({
context: { consulta: '', resultados: [], intentos: 0, error: null },
initial: 'inactivo',
states: {
inactivo: {
on: {
BUSCAR: {
target: 'buscando',
// EN LINEA: aqui event ya es el evento BUSCAR, sin comprobar nada
actions: assign({ consulta: ({ event }) => event.consulta }),
},
},
},
buscando: {},
},
})
La tentación evidente ante la versión nombrada es escribir ({ event }: any) y seguir. Es exactamente el as any que esta lección quiere erradicar, y hacerlo tiene consecuencias: el día que renombres el campo consulta en el evento, ese punto no dará error y la máquina asignará undefined en silencio. La comprobación explícita de event.type es honesta pero repetitiva. La salida limpia —que la lección siguiente desarrolla— consiste en dejar de depender del evento: si la action recibe por parámetro exactamente el dato que necesita, no le importa qué evento la disparó y el problema desaparece por construcción.
Cuando lleves la máquina a la interfaz, no vuelvas a escribir a mano la forma del context ni la unión de eventos: derívalas. ContextFrom, EventFromLogic y SnapshotFrom extraen del propio tipo de la máquina lo que necesites, de modo que un cambio en types se propaga solo a los componentes y las funciones auxiliares. Duplicar la declaración en la vista reintroduce por la puerta de atrás la desincronización que setup vino a eliminar.
El objetivo de types no es que el editor te ofrezca sugerencias; es decidir qué afirmaciones sobre tu dominio dejarán de depender de la vigilancia humana. Cada declaración es una verdad que trasladas del terreno de la disciplina al terreno de la estructura: al declarar el context decides que su forma no será negociable en ningún punto del programa; al declarar los eventos como unión discriminada decides que ningún componente podrá enviar un mensaje que la máquina no entienda; al declarar input y output decides que el contrato de creación y de terminación del actor será verificable en las fronteras. Lo que queda fuera de esas declaraciones sigue existiendo, pero sostenido únicamente por que alguien se acuerde. Por eso la asimetría del estrechamiento importa tanto: no es una molestia de la herramienta, es el punto exacto donde el sistema de tipos te informa de que estás pidiendo una verdad que tu diseño no puede garantizar, porque una implementación nombrada, por definición, ignora quién la llama. Ante ese aviso hay dos respuestas posibles y solo una es honesta. La deshonesta es as any: mantener la afirmación y desconectar al verificador, con lo que la verdad vuelve al terreno de la vigilancia y encima queda camuflada de código tipado, que es peor que no tener tipos. La honesta es cambiar el diseño para que la afirmación sea estructural: parametrizar la action para que no necesite saber del evento, o partir un estado para que la variante sea finita y viva en el grafo. Tipar bien una máquina no consiste en poner anotaciones hasta que el rojo desaparezca; consiste en escuchar dónde aparece el rojo, porque ahí es donde tu modelo del dominio y tu código dicen cosas distintas.
- Busca en tu código todos los
anyque aparezcan dentro de máquinas y clasifícalos: falta de declaración entypes, unión de eventos mal factorizada, o asimetría del estrechamiento. - Toma un evento con campos opcionales que finja ser varios casos y pártelo en una unión discriminada limpia. Anota cuántas comprobaciones en tiempo de ejecución desaparecen.
- Declara
inputentypesy construye elcontextinicial a partir de él concontext: ({ input }) => .... Comprueba qué error da el compilador si olvidas un campo. - Sustituye una comprobación de estado por estados en la vista por una
tagdeclarada entypes, y verifica que escribir una etiqueta inexistente no compila. - Escribe la misma actualización dos veces —en línea y nombrada— y compara qué tipo tiene
eventen cada una. Explica por qué la diferencia es inevitable dado el orden de las fases. - Deriva con
ContextFromyEventFromLogiclos tipos que tu componente necesita en vez de reescribirlos, y cambia un campo delcontextpara ver cuántos sitios se rompen a la vez.