wandres.dev
RESULT BUILDERS · DSLs en el lenguaje

Los métodos del builder: qué habilita cada uno

El catálogo completo de métodos que el compilador busca por convención: `buildBlock` para la yuxtaposición, `buildOptional` para el `if` sin `else`, `buildEither` para las ramas, `buildArray` para los bucles, más `buildExpression`, `buildLimitedAvailability` y `buildFinalResult`. Cada método es un permiso sintáctico.

⏱ 18 min

Un result builder no se declara conformando a un protocolo, y esa ausencia desconcierta la primera vez: no hay lista de requisitos que el compilador te obligue a satisfacer, ni error que te avise de que falta algo. Lo que hay es un conjunto de nombres que el compilador busca por convención cuando encuentra cada construcción del lenguaje dentro de un bloque transformado. Si el método existe, la construcción está permitida; si no existe, esa construcción queda sencillamente prohibida en tu DSL. Diseñar un builder es, por tanto, un ejercicio poco habitual: no estás escribiendo funciones, estás concediendo permisos sintácticos uno a uno, y cada permiso que concedes te obliga a responder qué significa esa construcción en tu dominio.

🎯 Al terminar esta lección sabrás
  • Enumerar los métodos del protocolo implícito y la construcción del lenguaje que habilita cada uno.
  • Explicar por qué un if sin else exige buildOptional y uno con else exige buildEither.
  • Justificar por qué un for necesita buildArray y un while no tiene equivalente.
  • Situar buildExpression, buildLimitedAvailability y buildFinalResult en la secuencia de transformación.

buildBlock y buildExpression: el suelo

buildBlock es el único método obligatorio. Recibe los resultados parciales de las sentencias de un bloque y devuelve el resultado combinado. Suele declararse variádico, aunque no tiene por qué serlo, y esa decisión tendrá consecuencias que veremos en la cuarta lección.

buildExpression es opcional y actúa antes, sobre cada expresión individual, convirtiéndola en un resultado parcial. Su utilidad real es la de una capa de adaptación: permite que el usuario del DSL escriba valores de tipos distintos y que todos aterricen en un tipo común antes de llegar a buildBlock.

@resultBuilder
enum ConstructorDeNodos {
    static func buildExpression(_ texto: String) -> [Nodo] { [.texto(texto)] }
    static func buildExpression(_ nodo: Nodo) -> [Nodo] { [nodo] }
    static func buildBlock(_ partes: [Nodo]...) -> [Nodo] { partes.flatMap { $0 } }
}

Hay una sobrecarga que casi siempre conviene añadir y que casi nadie recuerda: la que acepta un valor vacío. Sin ella, cualquier llamada del bloque que no devuelva nada provoca un error de inferencia difícil de leer.

extension ConstructorDeNodos {
    static func buildExpression(_ ignorado: Void) -> [Nodo] { [] }
}

Con esa línea decides explícitamente qué ocurre con las sentencias sin valor: ignorarlas en silencio, como aquí, o dejarlas prohibidas para que el bloque siga siendo una descripción pura. Ambas respuestas son defendibles; lo indefendible es no haberla elegido y que el usuario descubra la consecuencia mediante un mensaje incomprensible.

Con las sobrecargas de tipo, un bloque puede mezclar cadenas y nodos sin que el usuario convierta nada. Sin buildExpression, cada sentencia debe producir ya el tipo que buildBlock acepta, y la conversión recae en quien escribe el DSL. Hay además un beneficio de diagnóstico que se agradece: los errores de tipo se localizan en la línea concreta que no encaja, en vez de aparecer sobre el bloque entero.

buildOptional y buildEither: el control de flujo

Aquí está la parte conceptualmente interesante. Un condicional dentro de un bloque plantea un problema de tipos que no existe fuera: el bloque debe producir un valor de un tipo, pero un if tiene dos caminos posibles y el compilador no sabe cuál se tomará. Swift resuelve el caso de una rama y el de dos ramas con mecanismos distintos, y la razón es de tipos, no de comodidad.

Un if sin else significa que a veces no hay contribución. La transformación llama a buildOptional con un valor envuelto en un opcional:

static func buildOptional(_ parte: [Nodo]?) -> [Nodo] {
    parte ?? []
}

Un if con else significa que siempre hay contribución, pero puede venir de dos bloques cuyos tipos no tienen por qué coincidir. Por eso hacen falta dos métodos que actúan como las dos caras de una suma de tipos:

static func buildEither(first parte: [Nodo]) -> [Nodo] { parte }
static func buildEither(second parte: [Nodo]) -> [Nodo] { parte }

Sobre un builder cuyo resultado es siempre el mismo tipo, ambos métodos parecen redundantes hasta lo absurdo: los dos devuelven su argumento sin tocarlo. La sospecha se disipa en cuanto el builder es genérico y preserva los tipos, como el de SwiftUI: allí buildEither(first:) devuelve un tipo distinto de buildEither(second:), y la unión de ambos es un tipo condicional que recuerda, en su propio nombre, qué rama se tomó. Esa información sobrevive hasta el tiempo de ejecución y es lo que permite que el framework distinga entre cambiar el contenido de una vista y sustituirla por otra distinta. Un switch se transforma exactamente igual, anidando buildEither tantas veces como haga falta.

flowchart TB
sent[Sentencia dentro del bloque] --> tipo{Que clase de sentencia}
tipo -->|expresion suelta| be[buildExpression]
tipo -->|if sin else| bo[buildOptional]
tipo -->|if con else o switch| bei[buildEither first o second]
tipo -->|for in| ba[buildArray]
tipo -->|if hashtag available| bla[buildLimitedAvailability]
be --> bb[buildBlock combina los parciales]
bo --> bb
bei --> bb
ba --> bb
bla --> bb
bb --> bf[buildFinalResult opcional]
bf --> valor[Valor del bloque]
style sent fill:#cba6f7,color:#11111b
style bb fill:#89b4fa,color:#11111b
style valor fill:#a6e3a1,color:#11111b

buildArray y los bucles que no existen

Un for dentro de un bloque produce un número de contribuciones que solo se conoce en ejecución. La transformación recoge los resultados de todas las iteraciones en un array y lo entrega de una sola vez:

static func buildArray(_ partes: [[Nodo]]) -> [Nodo] {
    partes.flatMap { $0 }
}

Nota la firma: recibe un array de resultados parciales, no uno por iteración. El cuerpo del bucle se transforma como un bloque independiente y su resultado se acumula. La consecuencia práctica es que un bucle borra la estructura estática: dentro de él, el compilador ya no puede saber cuántos elementos habrá ni de qué tipo será cada uno, y por eso los DSL que dependen de la identidad posicional exigen una clave explícita en cada iteración.

Y aquí llega la ausencia más reveladora del catálogo: no hay método para while, ni para repeat, ni para guard, ni para defer, ni para do con catch. No es un olvido. Un while puede no terminar, y un builder debe producir un valor; un guard corta el flujo con un return que no encaja con una transformación que necesita llegar al final del bloque. Las construcciones permitidas son exactamente aquellas que se pueden traducir a una combinación de valores sin alterar la garantía de terminación del bloque.

Quedan dos métodos menores pero útiles. buildLimitedAvailability se invoca cuando el bloque contiene un if de disponibilidad de versión, y sirve para borrar del tipo resultante la información de tipos que solo existe en sistemas nuevos. buildFinalResult se aplica una sola vez al final, sobre el resultado ya combinado, y es la vía canónica para envolverlo en un tipo público distinto del tipo interno con el que has ido acumulando.

🧩

Cada método es un permiso

No implementar buildOptional no degrada tu DSL: prohíbe escribir un if sin else dentro de él. La ausencia de un método es una regla de tu lenguaje.

🔀

Dos ramas, dos caras

buildEither(first:) y buildEither(second:) parecen redundantes hasta que el builder preserva tipos. Entonces son las dos caras de una suma de tipos con memoria.

🎁

Interno y público

buildExpression normaliza la entrada y buildFinalResult envuelve la salida. Entre ambos puedes acumular en el tipo que te resulte cómodo sin exponerlo.

buildPartialBlock: acumular de dos en dos

Queda un método más reciente que resuelve un problema muy concreto de los builders que conservan tipos. Con buildBlock variádico y homogéneo no hay dificultad, pero un builder que quiera devolver un tipo distinto según el número y el tipo de sus hijos está obligado a declarar una sobrecarga por aridad: una para un hijo, otra para dos, otra para tres. Es exactamente lo que hacía SwiftUI, y por eso existía un tope.

La propuesta SE-0348 introdujo una alternativa que cambia la forma de la combinación: en vez de recibir todos los hijos a la vez, se acumulan por pares.

extension ConstructorDeNodos {
    static func buildPartialBlock(first parte: [Nodo]) -> [Nodo] { parte }
    static func buildPartialBlock(accumulated: [Nodo], next: [Nodo]) -> [Nodo] {
        accumulated + next
    }
}

Con esas dos declaraciones, un bloque de cualquier longitud queda cubierto: el compilador toma el primer resultado parcial, lo combina con el segundo, el resultado con el tercero, y así hasta el final. Es un plegado por la izquierda, y su virtud es que dos firmas fijas sustituyen a una familia infinita de sobrecargas sin renunciar a que el tipo cambie en cada paso. Cuando ambos mecanismos están presentes, buildPartialBlock tiene prioridad sobre buildBlock, de modo que puedes conservar el segundo como respaldo para versiones antiguas del compilador.

El precio es sutil pero real: el tipo resultante deja de ser una tupla plana y pasa a ser una anidación por la izquierda, más profunda cuantos más elementos haya. Para un builder homogéneo, donde el tipo no cambia, el mecanismo es puro beneficio; para uno que conserva tipos, hay que decidir si esa anidación es aceptable en los mensajes de error.

⚠️
El error que no dice lo que pasa

Si dentro de un bloque escribes un if sin else y tu builder no declara buildOptional, el compilador no dirá que falta ese método. Dirá que la clausura no puede inferir su tipo de retorno, o señalará una línea cualquiera del bloque. Ante un error incomprensible en un DSL, la primera hipótesis debe ser siempre una construcción sin método que la habilite.

Un protocolo sin protocolo, y por qué

Hay una rareza en el diseño que conviene mirar de frente, porque enseña algo sobre los límites del sistema de tipos de Swift. Todo en el lenguaje se declara con protocolos: los requisitos se escriben, el compilador los comprueba y el error te dice exactamente qué falta. Los result builders son la excepción deliberada: el conjunto de métodos se busca por nombre, sin declaración formal, en lo que la propuesta llama un protocolo informal. La razón no es pereza sino imposibilidad. Un protocolo real exigiría fijar las firmas, y las firmas de un builder no son fijables: buildBlock puede ser variádico o no, puede tener aridad fija, puede estar sobrecargado veinte veces, puede ser genérico con un número arbitrario de parámetros de tipo, y cada uno de esos parámetros puede llevar restricciones distintas. El ViewBuilder de SwiftUI, que verás en la cuarta lección, no tiene un buildBlock, sino una familia de sobrecargas genéricas cuyas firmas ningún protocolo de Swift podría describir de una vez. La consecuencia de esa elección la pagas y la disfrutas a la vez. La disfrutas porque tu builder puede ser tan expresivo como el sistema de tipos permita, preservando información estática que un protocolo habría aplanado. La pagas en diagnósticos: al no haber contrato declarado, el compilador no puede decirte que te falta un método, solo que algo no encaja, y la distancia entre la causa y el mensaje se vuelve enorme. Es un intercambio explícito de comprobabilidad por expresividad, y merece la pena reconocerlo como tal, porque reaparece en todo el diseño de lenguajes: cuanto más abierto es un punto de extensión, menos puede decirte la herramienta cuando lo usas mal. Los parameter packs, llegados años después, son en buena medida el intento de recuperar parte de esa comprobabilidad perdida.

📝
Lo esencial del catálogo

buildBlock es obligatorio y combina las sentencias de un bloque. buildExpression normaliza cada expresión antes. buildOptional habilita el if sin else, buildEither en sus dos formas habilita el if con else y el switch, buildArray habilita el for. buildLimitedAvailability atiende a las comprobaciones de versión y buildFinalResult envuelve el resultado. No hay método para while, guard ni defer, y por eso esas construcciones están prohibidas.

⚔️ Concede los permisos uno a uno
  1. Parte de un builder con solo buildBlock y comprueba qué error produce un if sin else dentro del bloque.
  2. Añade buildOptional y verifica que el mismo bloque compila; después añade un else y observa que vuelve a fallar.
  3. Implementa las dos formas de buildEither y comprueba que un switch de tres casos también compila.
  4. Añade buildArray y escribe un bloque que combine un for, un if con else y varias expresiones sueltas; dibuja el árbol de llamadas resultante.
  5. Intenta usar un while y un guard dentro del bloque; anota los errores y explica por qué no existe un método que los habilite.