wandres.dev
DSLS · lambdas con receptor

Builders con seguridad de tipos

Un DSL de construcción no es una lista de llamadas sino un árbol que se levanta de dentro hacia fuera. Esta lección estudia el patrón canónico del builder con receptor y su versión en tres líneas, la construcción de un árbol anidado donde cada nivel introduce su propio tipo y su propio vocabulario, la mecánica exacta de acumular hijos en el padre durante la ejecución del bloque, la separación entre el builder mutable y el resultado inmutable que produce, y las razones por las que esa separación no es un lujo académico sino la única forma de que el objeto construido sea seguro de compartir.

⏱ 20 min

El nombre con el que se conoce a esta técnica, builders con seguridad de tipos, es una respuesta a una pregunta histórica que hoy casi nadie recuerda haber tenido. Durante décadas, la forma habitual de describir una estructura anidada en un programa fue escribirla como texto en otro idioma: XML, JSON, una plantilla, un fichero de configuración. Ese texto era inerte para el compilador, así que un nombre mal escrito o un elemento colocado donde no corresponde no se descubría hasta que el programa llegaba a la línea que lo leía, normalmente en producción y normalmente un viernes. La promesa del builder con receptor es que la estructura se escribe directamente en el lenguaje anfitrión, con la misma apariencia declarativa que tenía el texto, pero cada palabra es una llamada tipada que el compilador comprueba antes de que exista un ejecutable. Lo que sigue es la mecánica precisa de esa promesa, que resulta ser mucho más simple que su reputación.

🎯 Al terminar esta lección sabrás
  • Construir el patrón canónico del builder con receptor y explicar el papel de cada una de sus tres líneas.
  • Levantar un árbol anidado en el que cada nivel introduce su propio tipo, su propio vocabulario y sus propias restricciones.
  • Describir con precisión el momento en que un hijo se añade al padre y por qué ese momento es siempre el mismo.
  • Separar el builder mutable del resultado inmutable y justificar la separación en términos de seguridad y de contrato.

Las tres líneas del patrón

El núcleo del patrón cabe en una función y no tiene ninguna parte oculta. Se crea una instancia mutable, se le entrega el bloque para que lo ejecute con ella como receptor, y se devuelve el resultado. Todo lo demás son variaciones sobre esas tres líneas.

class ConsultaBuilder {
    var tabla: String = ""
    private val condiciones = mutableListOf<String>()

    fun donde(expresion: String) { condiciones += expresion }

    fun construir(): String =
        "SELECT * FROM $tabla" +
            if (condiciones.isEmpty()) "" else " WHERE " + condiciones.joinToString(" AND ")
}

fun consulta(bloque: ConsultaBuilder.() -> Unit): String =
    ConsultaBuilder().apply(bloque).construir()

val sql = consulta {
    tabla = "usuarios"
    donde("edad >= 18")
    donde("activo = true")
}

Conviene leer con cuidado la línea que hace el trabajo. ConsultaBuilder() crea el acumulador; apply(bloque) invoca el bloque con esa instancia como receptor implícito, de modo que todo lo que el usuario escribe dentro son llamadas sobre ella; y construir() traduce el estado acumulado al valor que realmente interesa. El bloque no devuelve nada útil y por eso su tipo de retorno es Unit: su efecto entero es mutar el builder mientras se ejecuta.

Esa última observación tiene una consecuencia que sorprende a quien viene de la programación declarativa. El bloque de un DSL de construcción no es una descripción evaluada de forma perezosa: es código imperativo que se ejecuta de arriba abajo exactamente una vez, en el orden en que está escrito. Lo declarativo es la apariencia, no la ejecución, y esa diferencia se nota en cuanto alguien escribe un if o un for dentro del bloque y descubre, con alivio, que funciona.

val filtrada = consulta {
    tabla = "pedidos"
    if (soloRecientes) donde("fecha > '2026-01-01'")
    for (estado in estadosPermitidos) donde("estado = '$estado'")
}

Un tipo por nivel del árbol

La estructura anidada aparece cuando un builder ofrece funciones que, a su vez, aceptan un bloque con otro receptor. Cada nivel del árbol tiene su propia clase, y esa clase define exactamente qué puede escribirse allí. Un elemento que solo tiene sentido dentro de otro simplemente no existe como función fuera de él, y esa es la mitad de la seguridad de tipos que da nombre al patrón.

class MenuBuilder {
    private val entradas = mutableListOf<Entrada>()

    fun opcion(texto: String, accion: String) {
        entradas += Entrada.Opcion(texto, accion)
    }

    fun submenu(texto: String, bloque: MenuBuilder.() -> Unit) {
        val hijo = MenuBuilder().apply(bloque).construir()
        entradas += Entrada.Submenu(texto, hijo)
    }

    fun construir(): Menu = Menu(entradas.toList())
}

fun menu(bloque: MenuBuilder.() -> Unit): Menu = MenuBuilder().apply(bloque).construir()

La función submenu es el corazón del asunto y merece leerse instrucción a instrucción. Crea un builder nuevo, independiente del padre; le pasa el bloque del usuario para que lo ejecute con ese builder nuevo como receptor; le pide el resultado ya cerrado; y solo entonces lo añade a su propia lista. El hijo se incorpora al padre después de que su bloque haya terminado por completo, nunca antes, y eso significa que el árbol se construye de las hojas hacia la raíz aunque el código se lea de la raíz hacia las hojas.

sequenceDiagram
participant U as Bloque del usuario
participant P as Builder padre
participant H as Builder hijo
U->>P: submenu texto y bloque
P->>H: crea instancia nueva
P->>H: ejecuta el bloque con H como receptor
H->>H: acumula sus entradas
P->>H: pide construir
H-->>P: devuelve el nodo cerrado
P->>P: anade el nodo a su lista
⚠️
El orden real de creación es el inverso del orden aparente

Si dentro de un bloque hijo se produce una excepción, el padre nunca llega a añadir ese nodo y la estructura resultante queda incompleta sin ninguna señal, porque el apply del padre sí terminó. Del mismo modo, cualquier lógica que dependa de leer el estado del padre desde el hijo verá un padre a medio construir, con las entradas posteriores todavía ausentes. Ambas cosas dejan de ser sorprendentes en cuanto se interioriza que el bloque es código imperativo y que el hijo se cierra antes de existir para el padre.

Acumular y devolver

La distinción entre el objeto que acumula y el objeto que se devuelve es el segundo pilar del patrón, y es el que más veces se omite en el código real. El builder es mutable por necesidad: tiene que poder recibir aportaciones sucesivas desde el bloque. El resultado no debería serlo, porque va a viajar por el programa, a compartirse entre hilos y a servir de clave de comparación o de caché.

data class Menu(val entradas: List<Entrada>)

sealed interface Entrada {
    data class Opcion(val texto: String, val accion: String) : Entrada
    data class Submenu(val texto: String, val contenido: Menu) : Entrada
}

La llamada entradas.toList() dentro de construir no es un detalle cosmético. Sin ella, el Menu devuelto guardaría una referencia a la misma MutableList que el builder sigue teniendo, y cualquiera que conservase el builder podría alterar un menú ya entregado. La copia defensiva rompe ese vínculo y convierte el resultado en un valor cerrado del que nadie puede tirar hacia atrás.

// El builder queda inservible fuera del bloque
class MenuBuilder {
    private var cerrado = false
    fun construir(): Menu {
        check(!cerrado) { "este builder ya produjo su resultado" }
        cerrado = true
        return Menu(entradas.toList())
    }
}

La comprobación explícita es opcional pero educativa: hace visible que el builder tiene un ciclo de vida de un solo uso y que su existencia termina cuando el resultado nace. En un diseño maduro, el builder ni siquiera es público; lo público es la función de nivel superior y el tipo del resultado, de modo que el usuario nunca tiene en la mano una referencia al acumulador.

🏗️

El builder es andamio

Existe durante la ejecución del bloque y su única misión es recoger aportaciones. Si sobrevive a la construcción, alguien podrá modificar un resultado que ya se entregó como definitivo.

🌳

Cada nivel es un tipo

Lo que puede escribirse en un punto del árbol es exactamente el conjunto de miembros del builder de ese nivel. Ampliar el vocabulario de un nivel es añadir una función; restringirlo es quitarla.

🔒

El resultado es inmutable

Colecciones copiadas, propiedades de solo lectura y ningún camino de vuelta hacia el acumulador. Sin eso, el DSL construye estructuras que parecen valores y se comportan como estado compartido.

La seguridad de tipos de un builder es la migración de una clase entera de errores desde el tiempo de ejecución al tiempo de compilación

Vale la pena situar este patrón en la historia que lo produjo, porque su valor no está en la elegancia sino en una transferencia muy concreta de riesgo. Describir una estructura anidada en un formato externo, sea XML, JSON, YAML o una plantilla, significa aceptar que el compilador no entiende nada de lo que se escribe: los nombres de los elementos son cadenas, la anidación permitida es una convención documentada, los tipos de los valores son texto hasta que alguien los convierte, y la única validación posible ocurre cuando el programa ya está corriendo y ha llegado a la línea que lee el fichero. Ese modelo desplaza el descubrimiento de los errores al momento más caro posible y reparte la responsabilidad entre un esquema que casi nunca se mantiene y una documentación que casi nunca se lee. El builder con receptor hace la operación contraria y la hace sin perder ninguna de las virtudes del formato externo: la anidación permitida deja de ser convención y pasa a ser el conjunto de miembros de un tipo, de modo que escribir un elemento donde no corresponde es un error de compilación y no una excepción a las tres de la mañana; los valores dejan de ser texto y pasan a ser expresiones del lenguaje, con lo que se puede calcular, condicionar, iterar y reutilizar sin inventar un lenguaje de plantillas dentro del formato; el descubrimiento de qué se puede escribir deja de depender de la documentación y pasa a ser autocompletado, porque la lista de opciones es literalmente la lista de miembros que el entorno ya sabe mostrar; y el resultado deja de ser un mapa genérico de cadenas y pasa a ser un objeto tipado sobre el que el resto del programa puede razonar. Lo que se paga a cambio es que la estructura ya no puede cargarse desde disco sin recompilar, y esa es la frontera real del patrón: sirve para lo que se decide al escribir el programa, no para lo que se decide al desplegarlo. Todo lo demás, incluida la belleza del resultado, es una consecuencia secundaria de haber puesto el compilador a vigilar un territorio que antes estaba desatendido.

⚔️ Levanta un árbol y ciérralo bien
  1. Escribe el patrón de tres líneas para un tipo tuyo y sustituye después el apply por una construcción explícita con bloque() y return. Comprueba que el comportamiento es idéntico y explica qué hacía el apply.
  2. Añade un segundo nivel de anidamiento con su propio builder y sitúa un println en cada punto para observar el orden real de creación de los nodos. Contrástalo con el orden en que están escritos.
  3. Elimina la copia defensiva del resultado, conserva una referencia al builder fuera del bloque y modifica el objeto ya construido. Explica por qué el sistema de tipos no te lo impidió.
  4. Introduce en tu DSL una función que solo tenga sentido en el nivel interno y comprueba qué dice el compilador si se escribe en el externo. Describe el mecanismo exacto que produce ese error.
  5. Escribe un bloque que use un if y un for para generar nodos condicionales y argumenta por qué eso funciona sin que el diseñador del DSL haya previsto nada.