El patrón sobre la sintaxis: lo que viaja a cualquier lenguaje
Cuatro lenguajes y cuatro sintaxis irreconciliables han producido en las lecciones anteriores exactamente la misma estructura, y esta lección la destila hasta lo invariante: un tipo suma para el estado, un vocabulario cerrado de eventos, una función total de transición y una frontera nítida entre decidir y ejecutar. A partir de ahí construye la tabla de traducción entre entornos, define el gradiente de garantías que va de la convención a la demostración estática, y fija el criterio para responder en cualquier lenguaje la única pregunta que importa: si el compilador no lo comprueba, quién lo comprueba.
Las cuatro lecciones anteriores parecen tratar de cosas distintas y tratan de la misma. Un enum de Swift con valores asociados, una interfaz sellada de Kotlin, un enum de Rust y un átomo de estado de Erlang acompañado de sus datos son cuatro formas de escribir un único concepto: un conjunto finito de situaciones excluyentes, cada una con exactamente los datos que le corresponden. Y las cuatro funciones que las acompañan —el switch sobre el par, el reductor, la función que consume el valor, la retrollamada del estado— son cuatro formas de escribir la otra mitad: qué movimientos son legales desde dónde. Lo que cambia de un lenguaje a otro no es el patrón, sino quién verifica cada parte y en qué momento; y aprender a leer esa diferencia es lo que convierte un truco de un ecosistema en una herramienta de pensamiento que se lleva a cualquier sitio.
- Aislar las cuatro piezas invariantes del patrón, presentes en toda implementación por debajo de cualquier sintaxis.
- Traducir el modelo entre lenguajes sabiendo qué verifica el compilador de cada uno y qué queda a tu cargo.
- Situar cualquier entorno en el gradiente que va de la convención a la garantía estática y compensar los huecos.
- Decidir, en un lenguaje sin motor de statecharts, cuándo escribir la máquina a mano basta y cuándo no.
Las cuatro piezas invariantes
Si despojas cualquier implementación de su sintaxis, siempre quedan las mismas cuatro piezas. No son un estilo ni una recomendación: son las condiciones mínimas para que la frase la aplicación está en tal estado tenga un referente comprobable en el código en lugar de ser una historia que se cuenta el equipo.
Estado como tipo suma
Un conjunto cerrado de casos excluyentes donde cada caso transporta solo sus datos. Es lo que elimina de raíz la combinación imposible, y la única pieza que varios lenguajes regalan enteramente.
Evento como vocabulario cerrado
Las entradas del sistema también son un tipo suma. Sin esa lista cerrada, la transición no puede ser total y cada nuevo evento se cuela por un camino que nadie enumeró.
Transición como función total
Una función del par estado y evento al estado siguiente, sin efectos dentro. Es el lugar donde vive la legalidad, y su valor depende por completo de que sea el único camino de escritura.
Frontera entre decidir y ejecutar
Quien decide no toca el mundo y quien toca el mundo no decide. Esa separación es lo que hace la máquina comprobable sin arrancar la aplicación entera.
La tercera pieza es la que más veces se implementa a medias y la que sostiene a las demás. Un tipo de estado impecable no garantiza nada si el estado se puede escribir desde catorce sitios distintos, porque entonces el grafo que crees tener no existe en ninguna parte: existe repartido, y la pregunta de qué transiciones son legales solo se puede responder leyendo el programa completo. La prueba de fuego, en cualquier lenguaje, se formula en una línea: cuenta cuántas expresiones del código pueden asignar el estado. Si la respuesta no es uno, no tienes una máquina, tienes un tipo bien elegido.
Separar la decisión de la ejecución no es purismo funcional, es lo que permite que los tests de la máquina no necesiten red, ni reloj, ni pantalla, ni base de datos. Una función que recibe dos valores y devuelve uno se prueba exhaustivamente en milisegundos, y esa exhaustividad es la que convierte el grafo en un documento verificado en lugar de un dibujo. En cuanto la transición ejecuta un efecto por su cuenta, cada test necesita un entorno y la exploración completa del grafo deja de ser barata, que es tanto como decir que deja de hacerse.
El mismo grafo en tres sintaxis
Nada convence tanto como ver el mismo dominio escrito tres veces. Tomemos una máquina mínima de tres estados —a la espera, en curso y cerrada— con un dato que solo existe mientras hay algo en curso, y observemos qué cambia y qué no al cruzar la frontera de cada lenguaje.
enum Encargo {
case aEspera
case enCurso(desde: Date, reintentos: Int)
case cerrada(resultado: Resultado)
}
func siguiente(_ e: Encargo, _ ev: EventoEncargo) -> Encargo {
switch (e, ev) {
case (.aEspera, .arrancar): return .enCurso(desde: .now, reintentos: 0)
case let (.enCurso, .terminar(r)): return .cerrada(resultado: r)
default: return e
}
}
sealed interface Encargo {
data object AEspera : Encargo
data class EnCurso(val desde: Instant, val reintentos: Int) : Encargo
data class Cerrada(val resultado: Resultado) : Encargo
}
fun siguiente(e: Encargo, ev: EventoEncargo): Encargo = when {
e is Encargo.AEspera && ev is EventoEncargo.Arrancar -> Encargo.EnCurso(Instant.now(), 0)
e is Encargo.EnCurso && ev is EventoEncargo.Terminar -> Encargo.Cerrada(ev.resultado)
else -> e
}
pub enum Encargo {
AEspera,
EnCurso { desde: Instant, reintentos: u8 },
Cerrada { resultado: Resultado },
}
pub fn siguiente(e: Encargo, ev: EventoEncargo) -> Encargo {
match (e, ev) {
(Encargo::AEspera, EventoEncargo::Arrancar) => Encargo::EnCurso { desde: Instant::now(), reintentos: 0 },
(Encargo::EnCurso { .. }, EventoEncargo::Terminar(r)) => Encargo::Cerrada { resultado: r },
(otro, _) => otro,
}
}
Las tres versiones tienen la misma cardinalidad, el mismo grafo y la misma propiedad de que el instante de arranque no existe fuera del estado en curso. Las diferencias que quedan son reveladoras y todas menores: Kotlin necesita una condición compuesta porque no tiene coincidencia de patrones sobre tuplas y por eso su versión es la única que no obtiene exhaustividad sobre el par; Rust consume el valor de entrada, lo que hace imposible seguir usando el estado viejo después de la transición; y Swift ocupa un punto intermedio con coincidencia sobre el par pero sin consumo. Si sustituyes los tres nombres por los de tu dominio y borras la sintaxis, lo que queda escrito es el mismo diagrama tres veces.
La tabla de traducción
Con las cuatro piezas en la mano, cambiar de lenguaje es un ejercicio mecánico de sustitución. Lo que no es mecánico es saber qué te comprueba cada entorno, porque ahí las diferencias son enormes y la ilusión de haber trasladado el patrón entero es fácil de tener.
| Entorno | Estado exclusivo | Exhaustividad al consumir | Legalidad de la transición | Motor disponible |
|---|---|---|---|---|
| TypeScript | unión discriminada | por agotamiento con never |
a mano o con librería | XState y similares |
| Swift | enum con valores asociados |
verificada por el compilador | a mano | ninguno estándar |
| Kotlin | interfaz o clase sellada | verificada por el compilador | a mano | ninguno estándar |
| Rust en ejecución | enum con carga |
verificada por el compilador | a mano | ninguno estándar |
| Rust con type-state | el estado va en el tipo | no aplica | verificada al compilar | no hace falta |
| Erlang y Elixir | átomo de estado con sus datos | ninguna, todo en ejecución | la aplica el comportamiento | gen_statem en la estándar |
| Java moderno | interfaz sellada con registros | verificada por el compilador | a mano | ninguno estándar |
| Python o Go | clases o constantes por convención | ninguna | a mano | ninguno estándar |
Léela por columnas y no por filas, porque así aparece lo que de verdad distingue a estos entornos. La columna de exhaustividad separa los lenguajes donde añadir un estado produce una lista de errores de compilación de aquellos donde produce un fallo silencioso en producción tres semanas después. La columna de legalidad muestra algo más incómodo: salvo el type-state de Rust y el motor de la BEAM, en todas las demás filas la legalidad de las transiciones es responsabilidad tuya, y ningún compilador te avisará jamás de que alguien escribió un estado imposible de alcanzar según el grafo que dibujaste. La exhaustividad, que es lo que casi todo el mundo celebra al descubrir los tipos suma, cubre solo la mitad del problema.
En Python, en Go o en JavaScript sin tipos, las cuatro piezas se pueden escribir sin ninguna dificultad y siguen ordenando el código igual de bien. Lo que desaparece es el verificador: nada impide construir un estado con campos de otro, nada obliga a cubrir todos los casos al decidir, y añadir un estado no rompe nada hasta que rompe algo. La compensación es explícita y hay que presupuestarla: una única función constructora por estado, una prueba que recorra el grafo entero y falle si aparece un estado no contemplado, y una revisión que verifique que el estado se escribe en un solo sitio. Es exactamente el trabajo que el compilador hacía gratis en las filas de arriba.
El gradiente de garantías
Colocar tu entorno en la tabla anterior sirve de poco si no decides qué nivel de garantía necesita el problema concreto. Estos cinco escalones ordenan las opciones por lo que cada una hace imposible, y el criterio consiste en subir hasta donde el problema lo justifique y no un escalón más.
flowchart TD A[el conjunto de situaciones es cerrado y conocido?] -->|no| Z[no es una maquina: es un dato con validacion] A -->|si| B[tu lenguaje tiene tipos suma verificados?] B -->|no| C[escribe las cuatro piezas y compensa con pruebas] B -->|si| D[la secuencia legal se conoce al escribir el codigo?] D -->|si y es una interfaz publica| E[type state: la llamada ilegal no compila] D -->|no, la decide el usuario o la red| F[hay jerarquia historia o regiones a la vez?] F -->|no| G[tipo suma mas funcion reductora a mano] F -->|si| H[usa un motor de statecharts de verdad] style E fill:#cba6f7,color:#11111b style G fill:#a6e3a1,color:#11111b style H fill:#89dceb,color:#11111b
El escalón cero es la convención: los estados existen en la cabeza del equipo y en algún comentario. El primero cierra el conjunto de estados con un tipo suma y compra exhaustividad al consumirlo, que es donde se detiene la mayoría de los proyectos serios de Swift, Kotlin o Java. El segundo centraliza la transición en una función total y única, y ahí es donde aparece por primera vez la legalidad, comprobada en ejecución. El tercero sube la legalidad al sistema de tipos con type-state, y solo es posible cuando la secuencia se conoce al escribir el código. El cuarto delega en un motor y compra lo que ninguna de las cuatro piezas da por sí sola: jerarquía, historia, regiones que avanzan a la vez, temporizadores, aplazamiento de eventos, introspección del grafo, visualización y generación de casos de prueba desde el modelo.
Ese cuarto escalón conviene mirarlo con la lista en la mano, porque es donde se toma la peor decisión de este nivel en las dos direcciones. Quien escribe a mano un dominio con cinco regiones ortogonales y estados de historia acaba construyendo un intérprete peor que los que existen, con la diferencia de que el suyo no se puede dibujar ni recorrer automáticamente. Y quien mete un motor completo para tres estados y cuatro eventos paga una dependencia, un tiempo de ejecución y una curva de aprendizaje del equipo a cambio de una función de veinte líneas que el lenguaje ya verificaba. Entre esos dos errores, el segundo es el más frecuente fuera de JavaScript y el primero el más frecuente dentro.
Escribir la máquina a mano en un lenguaje con tipos te da coste cero en ejecución, ninguna dependencia, herramientas nativas de depuración y un modelo que cualquiera del equipo lee sin aprender una API. Lo que pierdes es concreto y conviene tenerlo escrito antes de decidir: el diagrama que se genera solo y no envejece, la semántica formal ya definida para los casos raros como las transiciones que cruzan niveles, la historia, las regiones en paralelo, el inspector en vivo y la generación de recorridos de prueba a partir del modelo. Si al leer esa lista solo echas de menos el diagrama, dibújalo a mano y sigue adelante; si echas de menos tres o más, el motor te está pidiendo entrar.
Al terminar este nivel conviene formular el aprendizaje de manera que sobreviva a la próxima moda del ecosistema, porque la formulación ingenua —aprendí una librería de máquinas de estado— caduca con la librería y la buena no caduca nunca. Lo que en realidad has adquirido es un criterio para preguntar de cualquier programa, en cualquier lenguaje, tres cosas concretas: cuáles son las situaciones excluyentes en las que este sistema puede encontrarse, qué datos tienen sentido solo dentro de cada una, y qué movimientos entre ellas son legales. Ese criterio es anterior al lenguaje y sobrevive al cambio de todos ellos, porque no describe una técnica sino una manera de mirar el dominio: en lugar de acumular variables independientes y descubrir sus contradicciones cuando un usuario las produce, se enumera primero el conjunto de configuraciones válidas y se hace que el resto sea inexpresable. La consecuencia práctica es una jerarquía de preferencias que no depende de la moda: primero busca en tu lenguaje la construcción que haga imposible el estado imposible, porque esa es gratis y es la más valiosa; después decide dónde vive la legalidad de las transiciones y asegúrate de que hay un solo camino de escritura, porque ahí es donde se cae casi todo el mundo con los tipos ya bien puestos; y solo entonces, cuando el dominio traiga de verdad jerarquía, simultaneidad, memoria de dónde estuvo o plazos que compiten entre sí, paga el precio de un intérprete. La medida de tu dominio del tema no será la fluidez con una API concreta, sino la velocidad con la que, ante un código ajeno lleno de booleanos correlacionados, veas el grafo que ese código está implementando mal sin saberlo.
- Toma una máquina tuya escrita con librería y enumera cuáles de sus prestaciones usas de verdad: jerarquía, historia, paralelismo, temporizadores, actores invocados.
- Reescríbela con las cuatro piezas invariantes en un lenguaje con tipos suma y anota qué prestación de la lista anterior perdiste en cada paso.
- Cuenta en la versión nueva cuántas expresiones pueden escribir el estado y redúcelas a una sola.
- Sitúa tu entorno principal en el gradiente y escribe en una frase qué compensación te toca hacer por el escalón que no alcanza.
- Busca en un lenguaje sin tipos suma una máquina implícita hecha de booleanos y describe el grafo que implementa sin tocar el código.
- Decide, con la lista de la primera pregunta delante, si esa máquina original merecía el motor o si el lenguaje ya le bastaba.