wandres.dev
EL COMPILADOR AMIGO · errores que enseñan

Refactorizar sin miedo: cambiar un tipo y dejar que el compilador te lleve

Refactorizar en un lenguaje sin garantías es un ejercicio de arqueología y de fe: se busca con expresiones regulares, se confía en la cobertura de pruebas y se despliega con el dedo cruzado. Esta lección desarrolla la alternativa que Elm hace posible y que sus usuarios describen como la razón principal por la que se quedan. El método consiste en modificar deliberadamente una declaración de tipo, compilar, y tratar la lista de errores resultante como una lista de tareas exhaustiva que el compilador ha redactado por ti. Se estudia el bucle completo con tres refactores canónicos: añadir un constructor a una unión, añadir un campo a un record y cambiar la firma de una función central. Después se explica con precisión por qué esa lista no tiene falsos negativos, apoyándose en la ausencia de null, la ausencia de escotillas de tipado, la exhaustividad obligatoria y la solidez del sistema de tipos. Y a continuación se enumera con honestidad la clase de cambios que el compilador no puede ver, junto con la técnica de modelado que convierte un cambio invisible en uno que sí rompe la compilación.

⏱ 18 min

Pregunta a alguien que lleve un par de años escribiendo Elm qué es lo que más echa de menos cuando vuelve a otro lenguaje y casi nunca contestará la inferencia de tipos, ni la ausencia de excepciones, ni los mensajes de error. Contestará que echa de menos poder cambiar de opinión. En un proyecto de tamaño mediano escrito sin garantías, modificar una decisión estructural tomada hace ocho meses es una operación cuyo coste nadie sabe estimar: hay que encontrar todos los sitios afectados con búsquedas de texto que dan falsos positivos y omiten los usos indirectos, hay que confiar en que la suite de pruebas cubra las ramas que se han roto, y hay que asumir que algo va a llegar a producción sin que nadie lo haya visto. Esa incertidumbre tiene un efecto que no aparece en ninguna métrica y que degrada los sistemas más que cualquier otra cosa: el código deja de mejorar. Las decisiones malas se rodean en vez de corregirse, los tipos se quedan como estaban y se añaden capas encima. Elm ataca ese problema por la raíz, y esta lección explica el mecanismo exacto: convertir un cambio de tipo en una lista de tareas completa, finita y escrita por la máquina.

🎯 Al terminar esta lección sabrás
  • Ejecutar el bucle de refactor dirigido por el compilador sobre los tres cambios canónicos: constructor nuevo, campo nuevo y firma modificada.
  • Justificar por qué la lista de errores resultante carece de falsos negativos, apoyándose en las garantías del sistema de tipos.
  • Usar Debug.todo y el orden de compilación para mantener el proyecto avanzando durante un refactor grande sin perder el hilo.
  • Identificar los cambios que el compilador no puede detectar y aplicar el modelado que los vuelve visibles.

El bucle: cambia un tipo y sigue los errores

El método es tan simple que cuesta creer que baste. Se modifica la declaración de tipo que representa la nueva verdad sobre el dominio, se ejecuta elm make, se lee el primer error, se arregla, y se vuelve a compilar. No hay fase de análisis previo, no hay búsqueda de usos, no hay lista escrita a mano de sitios pendientes: la lista es la salida del compilador, y termina cuando la compilación pasa. El cambio de hábito que exige es pequeño pero cuesta: hay que resistirse a arreglar el código antes de romperlo, y hay que empezar por el tipo aunque en ese instante nada compile.

-- Antes: un chiste solo podia estar en dos estados
type Estado
    = Borrador
    | Publicado


-- Despues: el dominio crece y aparece un tercer estado
type Estado
    = Borrador
    | Publicado
    | Archivado Posix


-- Cada case sobre Estado deja de compilar hasta cubrir el nuevo constructor
etiqueta : Estado -> String
etiqueta estado =
    case estado of
        Borrador ->
            "Sin publicar"

        Publicado ->
            "Visible"

        Archivado fecha ->
            "Archivado el " ++ formatearFecha fecha

El segundo refactor canónico es el campo nuevo en un record, y su comportamiento difiere del anterior de una forma instructiva. Añadir un campo rompe todos los sitios donde se construye el record completo, porque el literal deja de tener la forma exigida, pero no rompe los sitios donde solo se lee otro campo ni los que actualizan parcialmente. Es exactamente el comportamiento deseado: el compilador te lleva a donde hay una decisión que tomar, que es decidir qué valor tiene el campo nuevo en cada punto de creación, y te deja en paz donde no hay ninguna.

# El bucle completo, sin lista de tareas escrita a mano
elm make src/Main.elm --output=/dev/null

# 1. Cambia el tipo
# 2. Compila y lee el PRIMER error
# 3. Arregla solo ese
# 4. Vuelve a compilar
# ... hasta que la compilacion pase: entonces has terminado
💡
Debug.todo es el andamio del refactor grande

En un refactor que toca decenas de sitios conviene separar dos trabajos que la prisa tiende a mezclar: propagar la forma nueva y decidir la lógica nueva. La técnica consiste en tapar cada hueco con Debug.todo acompañado de una nota, seguir avanzando hasta que el proyecto compile entero, y solo entonces volver a rellenar los huecos uno por uno. La ventaja es que durante la primera pasada mantienes el mapa mental completo del cambio estructural sin distraerte con reglas de negocio, y durante la segunda tienes un proyecto que compila y una lista literal de decisiones pendientes que puedes buscar por nombre. El compilador, además, te recordará cada uno de esos marcadores en cuanto intentes construir para producción.

El tercer refactor canónico es el cambio de firma de una función central, y es el que mejor muestra el carácter transitivo del método. Modificar el tipo de retorno de una función que usan veinte sitios no produce veinte errores independientes: produce una onda que avanza por el grafo de llamadas, porque cada sitio corregido puede a su vez cambiar su propio tipo y romper a quien lo llamaba. El bucle sigue funcionando igual, pero conviene saber que el número de errores puede crecer antes de empezar a decrecer, y que ese aumento no es señal de que el refactor se esté descontrolando sino de que la onda todavía está propagándose.

-- Antes: la funcion prometia siempre un resultado
buscar : String -> List Chiste -> Chiste


-- Despues: el dominio admite que no haya ninguno
buscar : String -> List Chiste -> Maybe Chiste

Por qué la lista está completa

La confianza en este método no descansa en la costumbre sino en cuatro propiedades del lenguaje que se refuerzan entre sí, y conviene enunciarlas porque explican por qué el mismo bucle aplicado a otro lenguaje con tipos estáticos da un resultado más débil. La primera es que no existe ninguna escotilla de tipado: no hay un tipo comodín que absorba cualquier valor, no hay conversión insegura y no hay forma de decirle al compilador que confíe. Todo dato que entra en el programa pasa por un decodificador que declara su forma, de modo que no queda ningún punto opaco donde un tipo pueda cambiar sin que nadie se entere.

La segunda es la exhaustividad obligatoria del case, que convierte cada punto de decisión sobre una unión en un sitio que el compilador vigila. La tercera es la ausencia de despacho dinámico: como no existen métodos que se resuelvan en tiempo de ejecución, cada aplicación de función es un sitio estáticamente determinado y no hay implementaciones alternativas que se escapen del análisis. Y la cuarta es que el sistema de tipos es sólido, lo cual significa que no admite programas cuyos tipos mienten. La conjunción de las cuatro produce la propiedad que hace útil el método: si el proyecto compila después del cambio, no queda ningún sitio afectado sin revisar.

Vale la pena contrastar esto con lo que ocurre al aplicar el mismo bucle en un lenguaje con tipos estáticos pero con escotillas. Allí el compilador también enumera sitios, pero la lista es incompleta por construcción: cada conversión forzada, cada valor de tipo comodín, cada dato que entró sin validarse desde una respuesta de red y cada punto de despacho dinámico es un lugar por donde el cambio se cuela sin protestar. El resultado es peor que no tener ayuda, porque la lista parece completa y no lo es, y la confianza que genera se paga en producción. La diferencia entre ambas situaciones no es de grado sino de naturaleza: una lista con falsos negativos desconocidos no permite dar por terminado el trabajo.

🕳️

Sin escotillas

No hay tipo comodín ni conversión insegura. Ningún rincón del programa queda fuera del análisis, así que ningún uso puede esconderse.

Exhaustividad

Cada case sobre la unión modificada se convierte en un error hasta cubrir el constructor nuevo. Los puntos de decisión no se olvidan.

🎯

Sitios determinados

Sin despacho dinámico, cada llamada apunta a una única función conocida en compilación. No hay implementaciones ocultas que revisar.

🧾

Lista finita

El bucle termina. Cuando la compilación pasa, la lista de tareas se ha agotado y no queda un residuo pendiente de descubrir en producción.

flowchart LR
T[Cambiar la declaracion de tipo] --> C[elm make]
C --> E[Primer error con sitio exacto]
E --> F[Arreglar solo ese sitio]
F --> C
C --> OK[Compila: refactor completo]
style T fill:#cba6f7,color:#11111b
style E fill:#f9e2af,color:#11111b
style OK fill:#a6e3a1,color:#11111b

Lo que el compilador no ve

La honestidad exige delimitar la garantía, porque la confianza mal calibrada es peor que la desconfianza. El compilador detecta cambios de forma, no cambios de significado. Si dos parámetros contiguos tienen el mismo tipo y los intercambias, la firma sigue encajando y ninguna llamada se rompe, aunque el programa haya empezado a mentir. Si un campo llamado precio pasa de expresar euros a expresar céntimos, el tipo sigue siendo el mismo y no hay un solo error. Si un decodificador espera un campo que el servidor ha dejado de enviar, la compilación es perfecta y el fallo aparece en tiempo de ejecución como un valor de error que alguien tendrá que tratar. Y si un puerto cambia la forma del dato que envía desde JavaScript, la frontera no compila esa parte.

-- Peligro: dos Int contiguos con significados distintos
mover : Int -> Int -> Posicion -> Posicion


-- Remedio: tipos opacos que el compilador no puede confundir
type Fila
    = Fila Int


type Columna
    = Columna Int


mover : Fila -> Columna -> Posicion -> Posicion

El remedio es de modelado y no de disciplina: cuando un cambio importante resulta invisible al compilador, casi siempre es porque el tipo era demasiado ancho. Envolver un entero en un tipo propio, separar dos unidades que compartían representación o convertir una cadena en una unión de casos válidos son operaciones que cuestan unas líneas y que trasladan al sistema de tipos una distinción que antes solo vivía en la cabeza del equipo. A partir de ese momento, el mismo error que era silencioso pasa a formar parte de la lista que el compilador redacta.

Existe además una técnica deliberada para forzar la visibilidad de un cambio semántico cuando el tipo no puede estrecharse: renombrar. Si vas a cambiar el significado de un campo o de una función sin cambiar su tipo, cámbiale también el nombre en la misma operación. El renombrado rompe todos los usos, el compilador te lleva a cada uno y en cada visita tomas la decisión con el contexto delante. Es un uso instrumental del sistema de tipos que parece un rodeo y que en la práctica convierte el refactor más peligroso que existe, el que compila igual antes y después, en uno del mismo tipo que los demás.

⚠️
La compilación verde no es una prueba de corrección

Conviene decirlo sin adornos para no cultivar una falsa seguridad: que el proyecto compile después de un refactor significa que ningún sitio afectado por el cambio de forma ha quedado sin revisar, y no significa que las decisiones tomadas en cada sitio sean las correctas. Rellenar cada rama nueva con el valor que hacía callar al compilador es una forma muy eficaz de propagar un error por todo el programa con la bendición de la herramienta. El bucle garantiza cobertura, no juicio. Por eso las pruebas siguen siendo necesarias en Elm, aunque cambian de naturaleza: dejan de defenderte de valores ausentes y de casos no cubiertos, que ya no pueden ocurrir, y se concentran en lo único que queda, que es comprobar que la lógica hace lo que el dominio pide.

La refactorización barata no es una comodidad: es lo que determina si un sistema envejece o se pudre

Hay una forma de mirar todo esto que la convierte de truco de productividad en tesis sobre la vida de los sistemas, y merece la pena formularla entera. Todo programa que sobreviva a su primera versión va a descubrir que alguna de sus decisiones estructurales era incorrecta; no es un riesgo, es una certeza, porque el conocimiento del dominio siempre llega después del código que lo modela. Lo que decide el destino de ese programa no es cuántas decisiones malas contiene sino cuánto cuesta corregir una. Si corregir es caro e incierto, ocurre algo perfectamente racional a nivel individual y catastrófico a nivel del sistema: nadie corrige. Se añade un campo opcional al lado del que estaba mal, se escribe una función paralela que hace lo correcto para los casos nuevos, se rodea el módulo dudoso con una capa que traduce, y cada una de esas decisiones es defendible por separado. El resultado acumulado es un sistema donde el modelo del dominio ya no describe el dominio, donde cada funcionalidad nueva cuesta más que la anterior y donde llega un día en que alguien propone reescribirlo todo. La causa de esa trayectoria no fue la falta de talento ni la prisa: fue que el coste marginal de cambiar de opinión era demasiado alto y el equipo respondió al incentivo. Elm interviene exactamente en esa variable y en ninguna otra. No hace que aciertes a la primera, no impide el mal diseño y no sustituye al criterio; lo que hace es que corregir un tipo central un año después sea una tarde de trabajo mecánico con final garantizado en vez de una expedición sin mapa. Y cuando corregir es barato, la gente corrige, el modelo se mantiene alineado con el dominio y el sistema envejece en lugar de pudrirse. Esa es la razón profunda por la que quienes han vivido un refactor grande en Elm lo describen con una palabra que suena desproporcionada para hablar de un compilador, que es alivio: no están celebrando que la herramienta les ahorró búsquedas, están celebrando que recuperaron el derecho a cambiar de opinión sobre su propio programa.

⚔️ Rompe algo grande a propósito
  1. Añade un constructor a una unión central de un proyecto y cuenta cuántos errores produce la primera compilación antes de arreglar ninguno.
  2. Repite el refactor tapando todos los huecos con Debug.todo primero y rellenándolos después; compara esa experiencia con arreglarlos según aparecen.
  3. Añade un campo obligatorio a un record del modelo y explica por qué rompen los puntos de creación pero no los de lectura ni los de actualización parcial.
  4. Provoca un cambio invisible intercambiando dos parámetros del mismo tipo y comprueba que compila; después introduce tipos opacos y verifica que deja de compilar.
  5. Haz el mismo refactor del punto uno en un proyecto escrito en otro lenguaje y cronometra ambas operaciones, anotando cuántos sitios se te escaparon.
  6. Argumenta qué papel les queda a las pruebas automáticas cuando el compilador cubre forma y exhaustividad, y escribe una prueba que sí aporte valor en ese escenario.