wandres.dev
XSTATE V5: SETUP Y TIPOS · type-safety total

La API setup: declarar antes de construir

La decisión de diseño que define a XState v5: separar la fase de declaración de la fase de construcción. En la v4 escribías la máquina primero y explicabas después qué significaban sus nombres, y esa inversión del orden dejaba a TypeScript sin información en el momento crítico, obligando a un generador externo de tipos. La v5 antepone setup: un espacio donde se declaran los tipos del context y de los eventos, y las implementaciones de actions, guards, actors y delays, para que createMachine reciba un constructor que ya conoce el vocabulario legal. Esta lección diseca los cinco casilleros de setup, muestra por qué su valor de retorno no es un objeto de configuración sino una fábrica especializada, y explica por qué la desaparición de tsTypes es una consecuencia inevitable de haber puesto la declaración primero.

⏱ 18 min

La v4 de XState te dejaba escribir la máquina primero y explicar después qué significaban los nombres que habías usado dentro de ella. Esa libertad tenía un precio, y se pagaba entero en el sistema de tipos: cuando createMachine recibía la configuración, TypeScript aún no sabía qué era guardarUsuario, ni qué forma tenía el context, ni qué eventos podían llegar; la única salida era un generador de código externo —el recordado tsTypes— que reconstruía a posteriori lo que el compilador no había podido inferir a tiempo. La v5 invierte el orden, y con ello elimina la maquinaria entera: setup es una fase de declaración que ocurre ANTES de que exista la máquina, y su valor de retorno no es configuración sino un constructor especializado que ya conoce los tipos y la lista de nombres legales. Leer este cambio como una API nueva es quedarse en la superficie; es un cambio en el orden de las fases, y de ese orden se deriva casi todo lo demás del nivel.

🎯 Al terminar esta lección sabrás
  • Entender por qué setup precede a la máquina y qué defecto estructural de la v4 corrige ese orden.
  • Reconocer los cinco casilleros de setup: types, actions, guards, actors y delays.
  • Leer setup(...).createMachine(...) como dos fases: declarar el vocabulario y después escribir la gramática.
  • Explicar por qué la desaparición del generador tsTypes es consecuencia directa de esta inversión.

La deuda de la v4: construir antes de declarar

En la v4, createMachine aceptaba dos argumentos: la configuración y, opcionalmente, las implementaciones. El problema es de orden de evaluación conceptual: el primer argumento menciona nombres —asignaUsuario, esValido, cargarPerfil— cuyo significado solo aparece en el segundo. TypeScript infiere de izquierda a derecha, así que al comprobar el primer argumento no dispone todavía del contrato que le daría sentido.

// XState v4: la maquina se escribe antes de que existan sus nombres
const maquina = createMachine(
  {
    schema: {
      context: {} as { usuario: string | null },
      events: {} as { type: 'ENTRAR'; nombre: string },
    },
    tsTypes: {} as import('./maquina.typegen').Typegen0, // generado por una CLI
    initial: 'anonimo',
    states: {
      anonimo: { on: { ENTRAR: { target: 'dentro', actions: 'asignaUsuario' } } },
    },
  },
  { actions: { asignaUsuario: assign({ usuario: (_, e) => e.nombre }) } },
)

Aquel tsTypes no era un capricho: era una prótesis. Una CLI leía la máquina, deducía qué nombres existían y qué evento alcanzaba cada action, y escribía un fichero auxiliar que se importaba de vuelta para completar la inferencia. Funcionaba, pero introducía un paso de compilación externo, se desincronizaba en cuanto editabas sin regenerar, y hacía que el editor mintiera durante los segundos que tardaba en ponerse al día.

La v5 no mejora la prótesis: retira la causa. Si las declaraciones van primero, el compilador tiene toda la información cuando la necesita, y la inferencia ocurre sin ayuda externa.

import { setup, assign } from 'xstate'

// XState v5: primero se declara el vocabulario, despues se escribe la maquina
const maquina = setup({
  types: {
    context: {} as { usuario: string | null },
    events: {} as { type: 'ENTRAR'; nombre: string },
  },
  actions: {
    asignaUsuario: assign({ usuario: ({ event }) => event.nombre }),
  },
}).createMachine({
  context: { usuario: null },
  initial: 'anonimo',
  states: {
    anonimo: { on: { ENTRAR: { target: 'dentro', actions: 'asignaUsuario' } } },
    dentro: {},
  },
})
ℹ️
No es azúcar sintáctico: es orden de inferencia

La diferencia entre pasar las implementaciones como segundo argumento y declararlas en setup parece cosmética y no lo es. Con dos argumentos, ambos se comprueban contra un tipo genérico que aún no está resuelto, y por eso la v4 necesitaba el fichero generado. Con setup, la primera llamada RESUELVE los parámetros de tipo y devuelve una fábrica ya concreta; cuando createMachine recibe su único argumento, context, eventos, nombres de actions y de guards son valores conocidos, no incógnitas. La inferencia deja de ser un problema circular y pasa a ser una simple lectura de izquierda a derecha. Todo lo que este nivel te permitirá hacer —autocompletado de nombres, error al escribir un nombre inexistente, event estrechado en cada action— nace de ese detalle.

Anatomía de setup: cinco casilleros

setup acepta un objeto con cinco claves, y conviene entender que no son cinco listas equivalentes: una declara tipos y las otras cuatro declaran implementaciones asociadas a nombres.

🧬

types

El único casillero puramente estático. Declara la forma del context, la unión de eventos y, cuando hacen falta, input, output, tags, children o emitted. No genera código en tiempo de ejecución.

actions

Efectos y actualizaciones con nombre. Reciben un objeto con context, event y self, y opcionalmente un segundo argumento con parámetros. Aquí viven los assign, los logs y los envíos.

🚧

guards

Predicados puros con nombre que deciden si una transición se toma. Misma firma que las actions, pero devuelven un booleano y nunca provocan efectos.

🎭

actors

Los actor logics invocables o generables: fromPromise, fromCallback, fromObservable, fromTransition u otras máquinas. Declararlos aquí es lo que tipa invoke y spawn.

El quinto casillero es delays, el menos vistoso y el que más ahorra: asigna nombres a duraciones para que after no quede sembrado de números mágicos, y permite que esas duraciones se calculen a partir del context. Es además, como el resto de nombres, sustituible en un test, lo que convierte un temporizador de treinta segundos en un temporizador de cero durante las pruebas.

const conRetardo = setup({
  types: { context: {} as { intentos: number }, events: {} as { type: 'REINTENTAR' } },
  delays: {
    // backoff exponencial calculado desde el context
    esperaReintento: ({ context }) => Math.min(1000 * 2 ** context.intentos, 30_000),
  },
  guards: {
    quedanIntentos: ({ context }) => context.intentos < 5,
  },
}).createMachine({
  context: { intentos: 0 },
  initial: 'esperando',
  states: {
    esperando: {
      after: { esperaReintento: { target: 'reintentando', guard: 'quedanIntentos' } },
    },
    reintentando: {},
  },
})

El constructor especializado

Lo que setup devuelve no es un objeto de configuración: es una fábrica cerrada sobre lo que acabas de declarar. Ese objeto expone createMachine, y ese createMachine es distinto del importado de xstate, porque su tipo ya está parametrizado con tu context, tus eventos y la unión exacta de nombres que declaraste. De ahí salen las tres garantías que definen la experiencia de la v5.

flowchart LR
T[types: forma de context y eventos] --> S[setup]
I[implementaciones: actions guards actors delays] --> S
S --> F[fabrica especializada]
F --> M[createMachine con vocabulario cerrado]
M --> R[maquina tipada de punta a punta]
style S fill:#cba6f7,color:#11111b
style F fill:#89b4fa,color:#11111b
style R fill:#a6e3a1,color:#11111b

La primera garantía es el autocompletado: al escribir actions: dentro de una transición, el editor ofrece exactamente los nombres declarados. La segunda es el error temprano: un nombre no declarado ya no es una cadena cualquiera, sino un valor fuera de la unión, y el compilador lo rechaza en el sitio donde lo escribiste. La tercera es la coherencia: el context que declaraste es el mismo que ves en cada assign, en cada guard, en cada snapshot y en cada useSelector, sin un solo casting por el camino.

⚠️
Lo que setup no declara, la máquina no puede nombrar

La regla es simétrica y conviene interiorizarla ya: setup cierra exactamente el vocabulario que le pasas, ni un nombre más. Una función anónima escrita directamente en la transición no tiene nombre, no entra en la unión y, por tanto, ni se autocompleta, ni aparece en el diagrama con una etiqueta legible, ni se puede sustituir con provide en un test. A cambio conserva una ventaja que la lección siguiente examinará en detalle —conoce el evento exacto de su transición—, y esa asimetría es una de las tensiones reales de la v5, no un defecto que se pueda ignorar. Por ahora, la disciplina: escribe en setup todo lo que quieras poder nombrar, sustituir y ver dibujado.

El orden de las fases es la arquitectura

Casi todo el mundo lee setup como una API más ergonómica y ahí se detiene. Lo que de verdad ocurrió en la v5 es que el equipo identificó que el problema de tipado de la v4 no era un problema de tipos, sino un problema de FASES: la configuración de la máquina hablaba de un vocabulario que todavía no existía, y ningún sistema de inferencia puede resolver una referencia hacia adelante sin ayuda externa —de ahí el generador—. La solución no fue inventar tipos más listos, sino reordenar el programa para que la declaración precediera al uso, exactamente el mismo principio por el que un lenguaje exige declarar antes de usar y por el que un compilador hace una pasada de recolección de símbolos antes de comprobar los cuerpos. setup es esa pasada de recolección de símbolos, escrita a mano y en el espacio de usuario. Ese encuadre explica de golpe todas las decisiones que verás en las cuatro lecciones siguientes: por qué los tipos van en un casillero aparte y solo con anotaciones, por qué las implementaciones se nombran en lugar de escribirse en línea, por qué los actores deben declararse para que invoke sepa qué recibe y qué devuelve, y por qué migrar de v4 consiste sobre todo en mover código de después a antes. Y explica también la consecuencia que más disfrutarás: cuando el vocabulario está cerrado, el editor deja de ser un bloc de notas con colores y se convierte en un verificador de que la máquina que estás escribiendo es una máquina que existe. La ergonomía no fue el objetivo; fue el subproducto de haber puesto las fases en el orden correcto.

⚔️ Reordena las fases
  1. Toma una máquina cualquiera escrita con createMachine y su segundo argumento de implementaciones, y reescríbela con setup. Mueve cada bloque sin cambiar la lógica y observa qué empieza a autocompletarse.
  2. Escribe deliberadamente un nombre de action que no exista en setup y anota el mensaje exacto del compilador, en qué línea aparece y por qué la v4 no podía darlo ahí.
  3. Extrae a delays cualquier número literal que tengas dentro de un after y calcula uno de ellos desde el context, como el backoff del ejemplo.
  4. Convierte una condición escrita en línea en un guard con nombre y anota qué ganas al hacerlo y qué información sobre el evento pierdes en el camino.
  5. Argumenta en tres frases por qué el generador tsTypes era inevitable en la v4 e imposible de necesitar en la v5, sin mencionar la palabra ergonomía.
  6. Localiza en tu código una implementación anónima dentro de una transición y enumera las tres capacidades concretas que pierdes por no haberle dado nombre.