wandres.dev
RESULT BUILDERS · DSLs en el lenguaje

Límites y diagnóstico: errores crípticos y tiempos de compilación

Por qué un error dentro de un bloque de builder señala la línea equivocada, de dónde viene la explosión combinatoria del comprobador de tipos, qué herramientas existen para medirla, y qué decisiones de diseño mantienen un DSL usable a lo largo del tiempo.

⏱ 18 min

Los result builders tienen un defecto que ningún tutorial menciona y que todo el mundo padece: cuando algo va mal, el compilador rara vez dice qué. Un carácter de más en la línea doce produce un mensaje sobre la línea tres, o sobre el bloque entero, o una queja sobre la incapacidad de inferir un tipo que el programador nunca escribió. Y cuando nada va mal, el bloque puede tardar veinte segundos en compilar sin explicación aparente. Ninguna de las dos cosas es un fallo de implementación pendiente de arreglar: ambas se siguen del mecanismo tal como está diseñado, y esta lección explica por qué, qué se puede medir y qué decisiones de diseño mantienen un DSL habitable durante años.

🎯 Al terminar esta lección sabrás
  • Explicar el origen estructural de los mensajes de error desplazados o incomprensibles.
  • Relacionar la explosión del comprobador de tipos con la forma del builder y del bloque.
  • Aplicar un procedimiento sistemático de bisección y anotación para localizar un fallo.
  • Elegir decisiones de diseño que reduzcan el coste de compilación y mejoren los diagnósticos.

Por qué el error señala la línea equivocada

Hay tres causas superpuestas, y conviene separarlas porque cada una se ataca de una forma distinta.

La primera es que el código sobre el que falla la comprobación de tipos no es el que escribiste. Tras la transformación, tus doce sentencias son una única expresión gigantesca de llamadas anidadas. El comprobador trabaja sobre esa expresión y, cuando encuentra una inconsistencia, debe traducir la posición del fallo de vuelta al código fuente. Esa traducción es aproximada: muchas subexpresiones generadas no tienen una posición fuente propia, y la mejor aproximación disponible suele ser el inicio del bloque.

La segunda es que no hay contrato declarado. Como vimos, el conjunto de métodos es un protocolo informal buscado por nombre. Cuando escribes un if sin else y falta buildOptional, el compilador no puede decir que falta un método: solo constata que no encuentra una interpretación válida del bloque, y reporta la consecuencia en vez de la causa.

La tercera es la inferencia bidireccional. Un bloque de builder es una expresión sin anotaciones de tipo casi en ningún sitio, resuelta por un sistema de restricciones. Ante un fallo, el sistema no sabe cuál de las cincuenta restricciones es la incorrecta; solo sabe que en conjunto no tienen solución. Elegir cuál culpar es una heurística, y las heurísticas fallan.

// Un error real: la segunda linea no produce una vista
VStack {
    Text("Hola")
    print("depurando")
    Text("Adios")
}
// Mensaje tipico: no se puede inferir el tipo de retorno de la clausura,
// senalando la llave de apertura del VStack y no la linea culpable

El coste de compilación

El segundo problema es de tiempo, y su origen es medible. El comprobador de tipos de Swift resuelve restricciones explorando alternativas, y su coste crece de forma superlineal con el tamaño de la expresión. Un bloque de builder es una sola expresión, de modo que cada sentencia añadida no suma trabajo: lo multiplica. A eso se añaden tres agravantes propios del mecanismo.

Las sobrecargas de buildBlock y de buildExpression obligan a probar candidatos en cada nivel del anidamiento. Los literales numéricos son especialmente caros, porque cada uno admite muchos tipos posibles y multiplica el espacio de búsqueda. Y los operadores aritméticos o de concatenación dentro del bloque son el peor caso conocido: una expresión con varias sumas y una interpolación puede tardar más que el resto del archivo.

# Localizar los culpables reales, no los sospechosos habituales
swiftc -Xfrontend -warn-long-expression-type-checking=200 \
       -Xfrontend -warn-long-function-bodies=200 Archivo.swift

Esas dos banderas emiten un aviso por cada expresión o cuerpo de función que supere el umbral en milisegundos. Es la única forma honesta de saber dónde se va el tiempo; la intuición acierta poco, y casi siempre culpa al bloque más largo cuando el responsable es una línea con tres operadores.

El remedio es casi siempre el mismo, y consiste en cortar la expresión en trozos que el comprobador pueda resolver por separado:

// Caro: una sola expresion con literales, operadores e interpolacion
Text("Total: \(base * (1 + iva) - descuento) EUR")

// Barato: el calculo sale del bloque con tipo explicito
let total: Double = base * (1 + iva) - descuento
Text("Total: \(total) EUR")

Cada anotación de tipo actúa como una barrera: el comprobador resuelve el lado derecho de forma aislada y entrega un tipo ya cerrado al bloque, en lugar de arrastrar decenas de restricciones hasta la expresión global. La mejora es a menudo de un orden de magnitud, y no cuesta nada en tiempo de ejecución.

También conviene recordar los límites que ninguna técnica de diagnóstico arregla, porque son del mecanismo. Dentro de un bloque no puedes escribir un while, un guard con salida temprana ni un defer. Un throw dentro del bloque solo funciona si la clausura del builder está declarada como lanzadora, y un await solo si es asíncrona; ninguna de las dos cosas es automática. Y ningún builder puede evitar que sus partes se evalúen: cuando buildBlock las recibe, el trabajo ya está hecho. Reconocer estas fronteras ahorra horas de buscar una solución que no existe.

flowchart TB
err[Error incomprensible en un bloque] --> b1[Comentar la mitad del bloque]
b1 --> ok{Compila}
ok -->|si| b2[El fallo esta en la mitad comentada]
ok -->|no| b3[El fallo esta en la mitad activa]
b2 --> rep[Repetir la biseccion]
b3 --> rep
rep --> uno[Una sola sentencia sospechosa]
uno --> anot[Anotar tipos y extraer subexpresiones a variables]
anot --> causa[Causa localizada con mensaje util]
style err fill:#cba6f7,color:#11111b
style anot fill:#89b4fa,color:#11111b
style causa fill:#a6e3a1,color:#11111b

El procedimiento

Ante un error opaco, la improvisación cuesta horas y el método cuesta minutos. El orden que funciona es siempre el mismo.

Bisecar. Comenta la mitad del bloque y compila; repite sobre la mitad culpable hasta quedarte con una sentencia. Es tosco y es lo más rápido que existe, porque ataca directamente la causa del problema: la expresión gigante se vuelve pequeña.

Anotar. Sobre la sentencia sospechosa, saca las subexpresiones a variables locales con tipo explícito, fuera del bloque. Cada anotación elimina restricciones del sistema y convierte un error de inferencia en un error de tipos localizado, que sí dice la verdad.

Verificar los permisos. Si la sentencia sospechosa es un condicional, un switch o un bucle, comprueba que el builder implementa el método correspondiente antes de buscar cualquier otra explicación.

Aislar los efectos. Cualquier llamada dentro del bloque que no devuelva un valor del dominio es candidata a fallo, y la mayoría de los DSL no declaran la sobrecarga de buildExpression que acepta un resultado vacío.

Sacar la sentencia fuera. Si nada de lo anterior aclara el problema, extrae la sentencia sospechosa a una función independiente con tipo de retorno explícito y llámala desde el bloque. Fuera de la transformación, el compilador recupera toda su precisión y suele decir en una línea lo que dentro del bloque no sabía expresar. Es el truco más eficaz del repertorio y el que menos se usa.

Una advertencia sobre el orden: no intentes leer el mensaje original con atención antes de bisecar. En un bloque grande, ese mensaje casi nunca contiene información utilizable, y el tiempo invertido en interpretarlo es tiempo perdido. Reduce primero, lee después.

✂️

Bisecar antes que razonar

Comentar la mitad del bloque reduce la expresión y, con ella, el desplazamiento del mensaje. Es más rápido que cualquier lectura atenta del error original.

🏷️

Anotar para acusar

Un tipo explícito en una variable local elimina restricciones y obliga al compilador a señalar la incompatibilidad real en lugar de rendirse sobre el bloque.

⏱️

Medir antes que optimizar

Las banderas de aviso por expresión lenta identifican al culpable en segundos. La intuición sobre qué es caro en el comprobador de tipos acierta poco.

Diseñar para que siga siendo usable

Buena parte de la experiencia de quien use tu DSL se decide en tres o cuatro elecciones tuyas.

Prefiere un buildBlock variádico y homogéneo cuando no necesites conservar los tipos. Una sola firma en lugar de veinte sobrecargas reduce el espacio de búsqueda de forma drástica, y los mensajes mejoran de inmediato. Conservar tipos es una decisión cara que debe justificarse por lo que el framework hará después con ellos, como en el caso de SwiftUI.

Declara sobrecargas de buildExpression explícitas y estrechas. Cada sobrecarga que acepta un tipo concreto convierte un posible error de inferencia global en un error de argumento localizado. Añade una que acepte un resultado vacío para que las llamadas de efecto lateral fallen con claridad o se ignoren, según decidas.

Documenta los permisos. Escribe qué construcciones admite el bloque y cuáles no. Es información que el compilador no puede dar y que el usuario descubriría a base de errores desconcertantes.

Fija fronteras de tipo. Bloques cortos, subexpresiones extraídas a funciones con tipo de retorno anotado y ningún operador aritmético dentro del bloque. Cada frontera anotada corta la expresión y devuelve el coste de compilación a un régimen manejable.

Vigila el crecimiento. Un DSL que compilaba en un segundo puede degradarse sin que nadie toque el builder, simplemente porque los bloques que lo usan se han ido alargando. Merece la pena medir el tiempo de compilación de un archivo representativo de vez en cuando y tratar una subida como una regresión, igual que se trataría una de rendimiento en ejecución.

Hay una última decisión que se toma pronto y se paga tarde: cuántas sobrecargas de buildExpression exponer. Cada una amplía lo que el usuario puede escribir y multiplica el trabajo del comprobador en cada línea del bloque. Dos o tres bien elegidas cubren casi todo; una docena convierte cada sentencia en una pequeña búsqueda y hace que los errores dejen de nombrar el tipo culpable. Si dudas, empieza por una sola y añade las demás cuando el uso real las reclame, nunca por anticipación.

💡
La regla de las veinte líneas

Si un bloque de DSL supera la veintena de sentencias, extrae partes a funciones con tipo de retorno explícito. Ganas tiempo de compilación, mensajes de error localizados y legibilidad, y no pierdes absolutamente nada.

El precio de la magia sintáctica

Merece la pena mirar este nivel entero desde arriba, porque enseña algo que se repite en toda la ingeniería de lenguajes y de bibliotecas. Un result builder crea una distancia entre lo que el programador escribe y lo que la máquina comprueba, y todo lo bueno y todo lo malo del mecanismo salen de esa distancia. Lo bueno es evidente: una descripción de árbol que se lee como el dominio en vez de como un literal de datos, y un compilador que sigue verificándola de principio a fin. Lo malo es su reverso exacto: cuando la verificación falla, el compilador habla del código transformado, no del escrito, y no hay forma general de traducir hacia atrás. Esto no es exclusivo de Swift. Las macros de Lisp, las plantillas de C++, los tipos condicionales de TypeScript y las macros procedurales de Rust padecen la misma enfermedad y por el mismo motivo: cuanta más transformación media entre la fuente y la semántica, menos puede decirte la herramienta cuando algo se rompe. Los que mejor envejecen no son los que más pueden hacer, sino los que invierten en el camino de vuelta —posiciones fuente conservadas, mensajes redactados por el autor del DSL, errores que hablan del dominio y no del mecanismo—. Swift ha ido avanzando por ahí con lentitud, y el margen sigue siendo amplio. Para quien diseña, la conclusión práctica es contraria a la intuición: la calidad de un DSL no se mide por lo que permite expresar, sino por lo que ocurre cuando el usuario se equivoca. Un DSL que expresa el noventa por ciento del dominio y falla con mensajes claros vale más que uno que lo expresa entero y responde con una queja sobre un tipo que nadie escribió. Antes de añadir un permiso sintáctico a tu builder, pregúntate qué mensaje verá quien lo use mal. Si no sabes responder, ese permiso todavía no está listo.

📝
Lo esencial de los límites

Los errores se desplazan porque la comprobación ocurre sobre una expresión generada, porque no hay contrato declarado y porque la inferencia es global. Los tiempos crecen de forma superlineal con el tamaño del bloque, agravados por sobrecargas, literales y operadores. Bisecar y anotar tipos localiza la causa; las banderas de aviso por expresión lenta la miden. En el diseño, prefiere firmas simples, sobrecargas estrechas de buildExpression, bloques cortos y permisos documentados.

⚔️ Diagnostica y mide
  1. Introduce a propósito un error de tipo en la línea diez de un bloque de quince sentencias y anota qué línea señala el compilador.
  2. Aplica la bisección hasta aislar la sentencia culpable y mide cuántos pasos te hicieron falta.
  3. Compila con las banderas de aviso por expresión lenta y localiza las tres expresiones más caras de un archivo real.
  4. Sustituye un buildBlock de diez sobrecargas por uno variádico homogéneo y compara tiempos de compilación y mensajes de error.
  5. Escribe la documentación de permisos de tu DSL: qué construcciones admite, cuáles no y qué mensaje aparece al usar cada una de las prohibidas.