wandres.dev
TIPAR EL ESTADO · hacer imposible lo inválido

Hacer irrepresentable lo inválido, paso a paso

El eslogan de que los estados inválidos deben ser irrepresentables se cita mucho más de lo que se practica, porque casi nadie enseña el procedimiento. Esta lección lo convierte en un método de seis pasos con un criterio numérico de progreso —la cardinalidad del tipo frente al número de estados legítimos— y lo aplica de principio a fin sobre una pantalla de pago real, esa que tiene dirección, método, cupón y envío mezclados en un objeto plano lleno de opcionales. Por el camino aparece el catálogo de herramientas que hacen el trabajo: literales en lugar de `string`, tuplas y listas no vacías, correlación de campos dentro del miembro que los justifica, `readonly` para sellar y `satisfies` para comprobar sin ensanchar. Y termina donde debe: en el punto en que seguir apretando cuesta más de lo que devuelve.

⏱ 19 min

La frase circula desde hace décadas y suena a sentencia sin manual: haz que los estados inválidos sean irrepresentables. Todo el mundo asiente, casi nadie sabe por dónde empezar el lunes por la mañana, delante de un archivo de trescientas líneas donde el estado de una pantalla vive en un objeto plano con nueve campos, siete de ellos opcionales y ninguno documentado. Esta lección no repite el eslogan: lo desmonta en un procedimiento repetible, con un criterio objetivo para saber si vas ganando —la distancia entre cuántos valores admite tu tipo y cuántos admite tu producto— y una parada honesta para saber cuándo dejar de apretar. El material de trabajo es una pantalla de pago con sus cuatro decisiones encadenadas, del tipo que existe en cualquier aplicación que cobre dinero. Al final de la lección esa pantalla tendrá un tipo con exactamente tantos habitantes como situaciones reales, y todas las comprobaciones defensivas que la protegían habrán desaparecido porque ya no protegen de nada.

🎯 Al terminar esta lección sabrás
  • Aplicar un procedimiento de seis pasos para llevar un tipo desde el objeto plano hasta la representación exacta del dominio.
  • Medir el progreso comparando la cardinalidad del tipo con el número de estados legítimos del producto.
  • Reconocer campos correlacionados y moverlos al único caso que los justifica en lugar de dejarlos opcionales.
  • Decidir con criterio dónde parar, distinguiendo la garantía que compensa de la ceremonia que solo añade fricción.

El método: contar antes de tocar nada

La técnica descansa en una idea que puede sonar académica y es puramente práctica: todo tipo tiene un número de habitantes, y ese número se calcula. Un boolean tiene dos, una unión de tres literales tiene tres, un campo opcional multiplica por uno más y un objeto multiplica los habitantes de sus campos.

Ese número es tu instrumento de medida. Frente a él pones el otro, el que sale de hablar con el producto: cuántas situaciones distintas admite de verdad la pantalla. Mientras el primero supere al segundo, la diferencia no es un detalle de estilo, es la superficie exacta sobre la que pueden aparecer bugs que ninguna revisión de código detecta a simple vista.

El procedimiento tiene seis pasos y siempre el mismo orden. Uno, enumerar con el producto los estados legítimos, sin mirar el código. Dos, calcular la cardinalidad del tipo actual y anotar la diferencia. Tres, agrupar los campos que nacen y mueren juntos. Cuatro, convertir cada grupo correlacionado en un miembro de una unión discriminada. Cinco, eliminar los opcionales que hayan quedado huérfanos y sellar lo inmutable. Seis, borrar las comprobaciones defensivas que el compilador acaba de volver imposibles.

El orden importa más de lo que parece. Casi todo el mundo empieza por el paso cuatro, escribe una unión bonita y descubre a mitad de camino que le faltaba un estado porque nunca hizo el paso uno.

Enumerar antes de tipar evita ese vaivén, y además convierte el ejercicio en una conversación de producto y no solo en un refactor: la pregunta de cuántos estados hay se responde con quien conoce el negocio, no con quien conoce el compilador.

ℹ️
Cardinalidad: el número que convierte el eslogan en método

Contar habitantes suena a curiosidad de teoría de tipos hasta que lo usas dos veces. Un objeto con tres campos opcionales y un booleano tiene dieciséis habitantes como mínimo; si el producto reconoce cuatro situaciones, doce dieciseisavos del tipo son territorio sin significado. Ese cociente es tu indicador de progreso, y tiene la virtud de ser comprobable delante de otra persona: no discutes si el modelo es elegante, enseñas dos números. La meta no es siempre llegar a la igualdad perfecta, pero sí saber en todo momento cuánto te falta y decidir conscientemente si vale la pena recorrerlo.

La pantalla de pago, de plano a exacto

Este es el punto de partida, y probablemente reconozcas la forma porque la has escrito o heredado alguna vez.

type EstadoPago = {
  direccionId?: string;
  metodo?: "tarjeta" | "transferencia";
  tarjetaToken?: string;
  ibanUltimos4?: string;
  cupon?: string;
  descuento?: number;
  enviando: boolean;
  errorEnvio?: string;
  pedidoId?: string;
};

Paso uno, enumerar con el producto. Salen cuatro situaciones: eligiendo dirección, eligiendo método de pago, revisando el total antes de confirmar y pedido confirmado. El envío en curso y el fallo del envío son dos más, que aparecen solo desde la revisión.

Paso dos, contar. Con seis opcionales, un booleano y una unión de dos, la cardinalidad supera los mil habitantes. Los estados legítimos son seis. La diferencia no admite discusión estética.

Paso tres, agrupar lo correlacionado. Aquí está el hallazgo del refactor: metodo y tarjetaToken viven y mueren juntos, igual que metodo e ibanUltimos4, y cupon con descuento forman otra pareja inseparable. Que estén sueltos es lo que permite representar una transferencia con tarjetaToken relleno, o un descuento sin cupón que lo justifique.

type Pago =
  | { fase: "tarjeta"; token: string }
  | { fase: "transferencia"; ultimos4: string };

type Cupon =
  | { aplicado: false }
  | { aplicado: true; codigo: string; descuento: number };

Paso cuatro, convertir en unión discriminada el eje principal, que es el avance de la pantalla. Cada fase lleva encima lo que las anteriores ya decidieron, de modo que llegar a revisar significa, por construcción, tener dirección y método.

type EstadoPago =
  | { fase: "direccion" }
  | { fase: "metodo"; direccionId: string }
  | { fase: "revision"; direccionId: string; pago: Pago; cupon: Cupon }
  | { fase: "enviando"; direccionId: string; pago: Pago; cupon: Cupon }
  | { fase: "fallo"; direccionId: string; pago: Pago; cupon: Cupon; error: string }
  | { fase: "confirmado"; pedidoId: string };

Paso cinco, revisar lo que quedó. No hay ni un opcional, y no por casualidad: cada campo se mudó al único caso en el que existe siempre. El booleano enviando desapareció convertido en fase, y con él la posibilidad de estar enviando sin haber revisado.

Paso seis, borrar las defensas. La función que confirma el pedido ya no necesita comprobar que hay dirección ni que el método está completo, porque recibe la fase de revisión y esa fase, por definición, los trae. Cada comprobación que borras es una rama de código que ya no puede fallar ni necesita test.

Recuenta ahora y verás el resultado del ejercicio en números: seis miembros, ninguno opcional, cardinalidad prácticamente igual al número de situaciones reales una vez descontada la variedad de los identificadores y los importes. Del millar largo de habitantes iniciales queda lo que el producto reconoce, y esa reducción no se logró con más disciplina de equipo, sino cambiando la forma del tipo.

Conviene notar además qué se ganó de propina. Las firmas de las funciones pasaron a documentar sus precondiciones sin comentarios: una función que acepta la fase de revisión anuncia en su tipo que solo se puede llamar cuando hay dirección y método, y llamarla antes de tiempo dejó de ser un fallo que se descubre probando para ser uno que no compila.

flowchart LR
D[direccion] --> M[metodo con direccionId]
M --> R[revision con pago y cupon]
R --> E[enviando]
E --> C[confirmado con pedidoId]
E --> F[fallo con error]
F --> E
style D fill:#89b4fa,color:#11111b
style R fill:#cba6f7,color:#11111b
style C fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

El catálogo de herramientas

La unión discriminada hace el trabajo grueso, pero el ajuste fino usa otras piezas que conviene tener a mano y saber cuándo pesan.

La primera es sustituir tipos anchos por literales. Un campo declarado como string admite el conjunto infinito de las cadenas, y si tu dominio solo acepta tres, esa anchura es cardinalidad regalada; una unión de literales la recorta a lo justo y además convierte cada error de escritura en un fallo de compilación.

La segunda es dar forma a las colecciones. Una lista puede estar vacía, y si tu pantalla exige al menos un elemento, el tipo de lista miente; una tupla que declara primer elemento y resto expresa la no vacuidad y ahorra la comprobación de longitud en cada consumidor. La misma idea vale para pares que siempre van juntos, como un rango con inicio y fin.

La tercera es sellar lo que no debe cambiar. Marcar los campos como readonly no reduce el número de habitantes, pero elimina una familia entera de fallos que ningún modelado de casos evita: la mutación accidental de un estado compartido desde un consumidor que creía tener una copia.

La cuarta es comprobar sin ensanchar. Anotar un objeto con un tipo lo fuerza a cumplirlo pero también le borra la información literal; el operador satisfies comprueba la conformidad y conserva el tipo estrecho inferido, que es justo lo que necesitas en tablas de configuración y mapas de constantes.

💡
Los opcionales que sobreviven son una pregunta sin responder

Cuando termines el paso cinco, mira uno por uno los campos opcionales que hayan quedado. Cada uno esconde una de tres cosas: un caso del dominio que aún no has nombrado, un campo que en realidad pertenece a otro miembro, o un valor que sí puede faltar legítimamente y para el que existe un valor por defecto claro. Solo el tercero merece seguir siendo opcional. Los otros dos son deuda de modelado disfrazada de conveniencia sintáctica, y suelen ser exactamente los campos que aparecen en el informe del próximo incidente.

Dónde parar

Llevar la cardinalidad hasta la igualdad exacta es posible en muchos casos y contraproducente en algunos, y saber distinguirlos es lo que separa el criterio de la obsesión.

El primer límite es el coste de lectura. Una unión de doce miembros con siete campos repetidos en cada uno es técnicamente más precisa y prácticamente peor: nadie la mantiene, la gente empieza a hacer conversiones forzadas para esquivarla y la garantía se evapora por la puerta de atrás. Si la precisión te empuja a escribir aserciones de tipo, la precisión ya no está funcionando.

El segundo límite es la frontera del sistema. Los tipos gobiernan tu código, no los datos que llegan de fuera; un tipo perfecto que se rellena con la respuesta de una API sin validar es una promesa que nadie comprobó, y la lección cuatro de este nivel se dedica entera a cerrar esa grieta.

El tercer límite es la naturaleza de la regla. Que el total de un pedido sea la suma de sus líneas, o que un cupón no se aplique dos veces al mismo carrito, son invariantes que el sistema de tipos de TypeScript no alcanza a expresar de forma razonable, y forzarlas ahí produce firmas ilegibles que siguen sin garantizar nada.

El cuarto límite es organizativo, y suele decidir el resultado más que los otros tres. Un tipo exacto que solo entiende quien lo escribió no sobrevive a la primera persona que llega con prisa: lo rodeará, lo convertirá o lo duplicará, y a los tres meses convivirán el modelo preciso y una copia laxa que alguien usa porque le resultaba más cómoda.

La precisión que se sostiene es la que el equipo sabe leer y modificar, así que el nivel correcto de exactitud no es una propiedad del dominio, sino de la conversación entre el dominio y quienes lo mantienen.

El criterio de parada, entonces, es doble y bastante simple de aplicar. Sigue apretando mientras cada vuelta elimine un estado que alguien podría producir sin darse cuenta y no encarezca la lectura del tipo. Para en cuanto la siguiente vuelta solo elimine un estado que ya era imposible por otra razón, o cuando el tipo empiece a necesitar comentarios para entenderse.

Y anota la regla que no cabía en el tipo, porque no desaparece: cambia de sitio. Se convierte en un test, y ese traspaso es el asunto de la última lección del nivel.

Un tipo bien hecho es una especificación que se compila y una lista de tests que no tienes que escribir

La razón profunda por la que este método funciona no es que evite bugs, aunque los evite. Es que traslada la corrección desde el terreno de la vigilancia hasta el terreno de la construcción. En el modelo plano, que jamás convivan un descuento y la ausencia de cupón era una promesa distribuida entre todas las personas que tocan el archivo, sostenida por la atención de cada una en cada cambio, y las promesas sostenidas por atención se rompen exactamente el día que alguien tiene prisa. En el modelo exacto, esa convivencia no es que esté prohibida: es que no se puede escribir, y lo que no se puede escribir no necesita ser recordado, ni revisado en un pull request, ni cubierto por un test, ni documentado en un comentario que quedará obsoleto. Por eso el buen tipo no es documentación: la documentación describe lo que debería pasar y el tipo determina lo que puede pasar. La consecuencia práctica que notarás al aplicar los seis pasos sobre tu propio código es que el archivo adelgaza mientras aumenta la garantía, algo que casi ninguna otra técnica consigue a la vez. Y hay un efecto que tarda más en verse y vale más que el resto: cuando el estado tiene exactamente los casos del dominio, el código deja de discutir consigo mismo sobre qué situación es esta y empieza a limitarse a decir qué hacer en cada una. Cuenta los habitantes de tu tipo, cuenta los estados de tu producto y trabaja para acercar los dos números; toda la disciplina cabe en esa aritmética.

⚔️ Aplica los seis pasos a una pantalla tuya
  1. Elige una pantalla real con al menos cuatro campos de estado y enumera con alguien de producto sus situaciones legítimas, sin abrir el editor.
  2. Calcula la cardinalidad del tipo actual multiplicando las posibilidades de cada campo y anota la diferencia con el número anterior.
  3. Marca los campos que nacen y mueren juntos y agrúpalos, escribiendo al lado qué situación imposible permite hoy que estén sueltos.
  4. Escribe la unión discriminada resultante moviendo cada campo al único miembro que lo justifica, y comprueba que no queda ningún opcional sin razón.
  5. Borra todas las comprobaciones defensivas que hayan quedado sin sentido y cuenta cuántas líneas desaparecieron.
  6. Anota las reglas de tu dominio que no lograste expresar en el tipo; son la lista de trabajo para la última lección del nivel.