El arte de construir con piezas inteligentes: combinators en la era de los LLMs

En la industria solemos discutir sobre lenguajes, frameworks y arquitecturas, pero hay una técnica nacida en el corazón de la programación funcional que, casi sin que nos demos cuenta, se volvió una de las herramientas más elegantes para diseñar interfaces de software: los combinators.
Si alguna vez sentiste que programar se parece a jugar con Lego, es porque estabas usando, de forma intuitiva, el poder de la composición. Lo interesante es lo que pasa cuando llevamos esa idea al extremo y la usamos para que los modelos de lenguaje generen mejor código por nosotros.
¿Qué es exactamente un combinator?
El término puede sonar a teoría de categorías o a álgebra abstracta, pero en la práctica es bastante más sencillo: un combinator es una función que toma piezas simples y las combina para formar una pieza más compleja, sin agregar lógica externa “mágica”.
La clave está en que la pieza nueva tiene la misma forma que las originales. Eso permite seguir combinándola con otras del mismo tipo, sin fricción, formando un árbol que crece de manera natural.
Imaginá dos bloques:
- una función que sabe leer un archivo,
- otra que sabe filtrar texto.
Un combinator es la herramienta que pega esos dos bloques y devuelve un tercero — un lector-filtrador — que se puede seguir combinando como si fuera primitivo. En esencia, los combinators permiten definir un pequeño lenguaje (un DSL embebido) dentro del propio código, donde las reglas de cómo se unen las piezas son claras y estrictas.
La idea es vieja: viene del cálculo combinatorio de Schönfinkel y Curry de los años 20, y aterrizó en la programación moderna a través de Haskell, Scala, OCaml y compañía. Pero recién ahora, con los LLMs cambiando cómo escribimos software, está mostrando un costado que pocos vieron venir.
A lo largo del artículo voy a usar TypeScript para los ejemplos. Es un lenguaje en el que casi todos podemos leer código, y su sistema de tipos es lo suficientemente expresivo como para mostrar el corazón del asunto: cómo los tipos garantizan que las piezas encajen.
Ejemplo 1: parsers, el “Hola Mundo” de los combinators
Es el caso de uso clásico. En lugar de escribir una expresión regular gigante e ilegible para parsear algo, se construye el parser combinando piezas chiquitas. Empecemos por el tipo:
// Un parser es una función que toma una entrada (string)
// y, si tiene éxito, devuelve un valor de tipo T más lo que quedó sin consumir.
type Parser<T> =
(input: string) =>
| { ok: true; value: T; rest: string }
| { ok: false };Ese Parser<T> es la "forma" común. Todo lo que vamos a definir va a entrar y salir con esa misma forma. Esa es la regla del juego.
Primitivas (parsers básicos):
const char = (c: string): Parser<string> =>
(input) =>
input[0] === c
? { ok: true, value: c, rest: input.slice(1) }
: { ok: false };
const digit: Parser<number> = (input) => {
const ch = input[0];
return ch && /[0-9]/.test(ch)
? { ok: true, value: Number(ch), rest: input.slice(1) }
: { ok: false };
};Combinators (toman parsers y devuelven parsers):
// "Esto Y después esto otro": combina dos parsers en secuencia.
const and = <A, B>(pa: Parser<A>, pb: Parser<B>): Parser<[A, B]> =>
(input) => {
const r1 = pa(input);
if (!r1.ok) return { ok: false };
const r2 = pb(r1.rest);
if (!r2.ok) return { ok: false };
return { ok: true, value: [r1.value, r2.value], rest: r2.rest };
};
// "Esto O esto otro": prueba el primero, y si falla, el segundo.
const or = <A>(pa: Parser<A>, pb: Parser<A>): Parser<A> =>
(input) => {
const r1 = pa(input);
return r1.ok ? r1 : pb(input);
};
// "Repetir cero o más veces": aplica el parser hasta que falle.
const many = <A>(pa: Parser<A>): Parser<A[]> =>
(input) => {
const out: A[] = [];
let rest = input;
while (true) {
const r = pa(rest);
if (!r.ok) break;
out.push(r.value);
rest = r.rest;
}
return { ok: true, value: out, rest };
};Mirá las firmas. Esto es lo importante:
- and toma Parser<A> y Parser<B> y devuelve Parser<[A, B]>.
- or toma dos Parser<A> y devuelve Parser<A>.
- many toma Parser<A> y devuelve Parser<A[]>.
Los tipos te dicen exactamente cómo encajan las piezas. Si intentás and(digit, "hola"), el compilador frena: "hola" no es un Parser<B> para ningún B. Si intentás or(digit, char('a')), también frena: uno es Parser<number> y el otro Parser<string>, y el or exige que ambos devuelvan el mismo tipo.
Ahora componer es trivial. Un parser de “uno o más dígitos como número”:
const integer: Parser<number> = (input) => {
const r = and(digit, many(digit))(input);
if (!r.ok) return { ok: false };
const [head, tail] = r.value;
return { ok: true, value: Number([head, ...tail].join("")), rest: r.rest };
};A partir de acá un parser de JSON, de fechas, de un mini lenguaje, lo que quieras, sale por composición. Las librerías reales en TypeScript que llevan esto a producción son Parsimmon, Arcsecond y parjs; en Haskell, Parsec y megaparsec son la referencia. El paper fundacional es de Hutton y Meijer, Monadic Parser Combinators (1996), que muestra cómo los parsers forman una mónada.
Un caso real: el equipo de Mercury usó Parsimmon para construir un date picker que entiende entradas como “last 3 months” o “since March”. Ahí se ve cómo, donde una regex se vuelve impenetrable, los combinators ofrecen algo legible, testeable y extensible.
Ejemplo 2: queries sin concatenar strings
El mismo patrón aplicado a bases de datos. Definamos un tipo para predicados sobre una tabla:
// Un predicado es un fragmento de WHERE tipado por la tabla T.
type Predicate<T> = {
sql: string;
params: unknown[];
// El parámetro `_phantom` no se usa en runtime; sirve para que TypeScript
// sepa sobre qué tabla aplica este predicado.
readonly _phantom?: (t: T) => void;
};
type User = { id: number; age: number; active: boolean };Primitivas sobre User:
const esAdulto: Predicate<User> = {
sql: "age >= ?",
params: [18],
};
const tieneSubActiva: Predicate<User> = {
sql: "active = ?",
params: [true],
};Combinators que componen predicados:
const and = <T>(p1: Predicate<T>, p2: Predicate<T>): Predicate<T> => ({
sql: `(${p1.sql}) AND (${p2.sql})`,
params: [...p1.params, ...p2.params],
});
const not = <T>(p: Predicate<T>): Predicate<T> => ({
sql: `NOT (${p.sql})`,
params: p.params,
});Y la composición:
const usuariosVip: Predicate<User> = and(esAdulto, tieneSubActiva);
// usuariosVip.sql -> "(age >= ?) AND (active = ?)"
// usuariosVip.params -> [18, true]El parámetro _phantom es lo que se conoce como phantom type: no existe en runtime, pero le dice a TypeScript que esAdulto solo es válido sobre User. Si tenés otra tabla type Post = ..., intentar and(esAdulto, predicadoSobrePost) es un error de compilación. El SQL deja de ser un string suelto y se vuelve un valor tipado, componible y reutilizable.
Esto no es teórico: librerías como Drizzle en TypeScript, Slick en Scala, Diesel en Rust o Esqueleto en Haskell aplican exactamente este patrón. Refactors masivos se vuelven casi triviales, y el “SQL como string” deja de ser la fuente número uno de bugs en runtime.
Ejemplo 3: interfaces gráficas como combinators
React, SwiftUI y Elm se apoyan fuertemente en esta idea. En React, un higher-order component (HOC) es literalmente un combinator sobre componentes:
import { ComponentType } from "react";
// Un combinator que envuelve cualquier componente y le agrega
// un spinner mientras `isLoading` esté en true.
const withLoading = <P extends object>(
Component: ComponentType<P>
): ComponentType<P & { isLoading: boolean }> =>
({ isLoading, ...props }) =>
isLoading
? <Spinner />
: <Component {...(props as P)} />;La firma habla sola: withLoading toma un componente con props P y devuelve un componente con props P & { isLoading: boolean }. La forma se preserva, los tipos se enriquecen. Y como el resultado es otra vez un componente, lo podés volver a envolver:
const withAuth = <P extends object>(
Component: ComponentType<P>
): ComponentType<P> => /* ... */;
const PerfilProtegido = withAuth(withLoading(Perfil));Para una UI compleja, es mucho más manejable componer un árbol con reglas claras de anidación que escribir HTML/CSS suelto y rezar. Y para un LLM, esta estructura es mucho más fácil de generar bien.
Ejemplo 4: orquestación de agentes y pipelines de IA
Acá es donde el patrón se pone realmente interesante. Modelemos las interacciones con LLMs como un álgebra. La pieza básica:
// Un step de IA toma un input y produce, asincrónicamente, un output.
// Esa es la "forma" común a todo el sistema.
type Step<I, O> = (input: I) => Promise<O>;Primitivas:
const prompt =
(template: string): Step<Record<string, string>, string> =>
async (vars) => {
const filled = fillTemplate(template, vars);
return await llm.complete(filled);
};
const extractJSON = <T>(schema: Schema<T>): Step<string, T> =>
async (text) => parseAndValidate(text, schema);Combinators:
// Encadena dos steps: la salida del primero alimenta al segundo.
const chain = <A, B, C>(s1: Step<A, B>, s2: Step<B, C>): Step<A, C> =>
async (input) => s2(await s1(input));
// Ejecuta dos steps en paralelo sobre el mismo input.
const parallel = <I, A, B>(
s1: Step<I, A>,
s2: Step<I, B>
): Step<I, [A, B]> =>
async (input) => Promise.all([s1(input), s2(input)]);
// Reintenta hasta que el verificador acepte el resultado.
const retryUntil = <I, O>(
s: Step<I, O>,
verify: (out: O) => boolean,
maxAttempts = 3
): Step<I, O> =>
async (input) => {
for (let i = 0; i < maxAttempts; i++) {
const out = await s(input);
if (verify(out)) return out;
}
throw new Error("Verificación falló tras todos los intentos");
};Otra vez, mirá las firmas. chain exige que el output de s1 coincida exactamente con el input de s2. Si encadenás un step que devuelve string con uno que espera User, el compilador te frena. Vos describís el grafo de procesamiento, los tipos te garantizan que los datos fluyen bien:
const analizarReseña: Step<{ texto: string }, { sentimiento: string; resumen: string }> =
chain(
prompt("Analizá esta reseña: {texto}. Devolvé JSON con sentimiento y resumen."),
extractJSON<{ sentimiento: string; resumen: string }>(reviewSchema)
);Una vez que tenés ese vocabulario, un agente complejo deja de ser un script enredado de llamadas a una API y pasa a ser una expresión compositiva que se lee casi como pseudocódigo. Frameworks como LangChain, LangGraph y especialmente DSPy están explorando este terreno: DSPy trata los prompts como módulos optimizables y compone pipelines como programas tipados.
El rol de los sistemas de tipos: un manual de instrucciones automático
Los tipos funcionan como las muescas de los Lego: impiden que intentes unir piezas que no encajan. En cada uno de los ejemplos vimos lo mismo:
- Parser<T> se compone con otros Parser<U> siguiendo reglas estrictas.
- Predicate<User> no se mezcla con Predicate<Post>.
- Step<A, B> solo encadena con Step<B, C>.
El compilador te detiene antes de ejecutar nada. No es una garantía formal completa, pero filtra una enorme cantidad de errores que en otros paradigmas solo aparecen en producción. En lenguajes con sistemas de tipos más expresivos — Haskell, Scala, Rust — este patrón llega más lejos todavía.
Combinators y LLMs: programación para modelos de lenguaje
Hoy el programador ya no es el único que escribe código. Claude, GPT, Gemini y compañía están en el asiento del copiloto. Y aunque son sorprendentemente capaces, todavía sufren con la lógica compleja y producen código “espagueti” difícil de validar. Acá los combinators se vuelven un puente especialmente útil.
Restricción del espacio de búsqueda. En lugar de pedirle al modelo que escriba 100 líneas de código crudo, le pedimos que combine primitivas de una librería bien diseñada. Es muchísimo más fácil para un LLM elegir entre 5 combinators válidos — chain, parallel, retryUntil...— que inventar lógica desde cero. Menos superficie de error, más predictibilidad.
Validación inmediata. Si el modelo arma una combinación inválida, el sistema de tipos falla. Ese error es señal precisa, no ruido: se puede reinyectar al modelo en un loop de auto-corrección determinista. Es exactamente lo que hace retryUntil del ejemplo anterior, pero aplicado al propio proceso de generar código.
Estructura jerárquica. Los combinators fuerzan árboles, no cadenas desordenadas de tokens. Y resulta que los LLMs son muy buenos generando estructuras anidadas cuando la gramática está bien definida.
Esto último conecta con una línea de investigación muy activa: el constrained decoding o grammar-constrained decoding. La idea es restringir, en tiempo de inferencia, qué tokens puede emitir el modelo en cada paso para que la salida respete una gramática formal. Trabajos como el de Geng et al. (2024) sobre grammar-constrained decoding y motores como XGrammar muestran que se puede forzar a un LLM a generar exclusivamente JSON válido, SQL bien formado, o cualquier DSL definido por una gramática libre de contexto, sin sacrificar mucha performance.
Si tu librería de combinators tiene una gramática formal — que es justo lo que tienen por construcción — podés usar técnicas de constrained decoding para que el modelo, literalmente, no pueda escribir código que no compile. La intersección entre programación funcional clásica y generación con LLMs es uno de los espacios más fértiles ahora mismo.
Álgebras y leyes: la guía para una IA razonable
Muchas veces, un conjunto de combinators define lo que se llama un álgebra. Esto significa que las combinaciones cumplen leyes matemáticas: asociatividad (no importa cómo agrupes las piezas mientras el orden sea el mismo), existencia de elemento identidad (una pieza neutra), distributividad sobre ciertas operaciones.
Por ejemplo, en el chain que definimos arriba:
chain(chain(a, b), c) ≡ chain(a, chain(b, c))Las dos expresiones son intercambiables. Y eso permite tres cosas.
Optimización. El sistema (o el modelo, guiado por reglas) puede refactorizar para mejorar performance sin romper lógica.
Razonamiento simbólico. Podemos guiar al modelo para que aplique transformaciones que respeten las leyes del álgebra. Esto se acerca a lo que herramientas como QuickCheck ya hacen en testing: verificar propiedades en lugar de casos puntuales.
Menos alucinaciones. Al limitar las opciones del modelo a “combiná estas N funciones” en lugar de “escribí cualquier cosa”, reducimos drásticamente el espacio donde puede alucinar. El modelo deja de ser un generador libre y pasa a ser un ensamblador de piezas válidas.
Una nota sobre los costos
No quiero pintar este enfoque como gratis. Los combinators tienen su lado oscuro: la curva de aprendizaje es real (sobre todo cuando aparecen mónadas, u otras estructuras algebraicas similares), los mensajes de error en librerías muy tipadas pueden ser indescifrables — pregúntenle a cualquiera que peleó con tipos en Parsec o con HKTs en Scala — , y hay un costo de runtime no despreciable comparado con código imperativo equivalente. Para problemas chicos a veces es overkill. Pero en dominios donde la corrección y la composabilidad importan más que el atajo — parsers, queries, pipelines de IA, DSLs — el costo se paga solo.
Volver a las raíces para construir el futuro
Hay algo casi irónico en esto: para sacarle jugo a la tecnología más nueva del momento, las mejores herramientas vienen de la informática teórica más vieja y sólida. Los combinators no son solo una técnica elegante de programación funcional; son una de las interfaces más naturales para que humanos y modelos colaboren en construir software complejo, seguro y predecible.
Diseñar una librería hoy ya no es solo pensar en el desarrollador humano. Es diseñar un lenguaje de bloques combinables que un modelo pueda entender, respetar y potenciar. Y eso, viniendo de una idea de los años 20, no deja de ser una linda vuelta de tuerca.
Referencias
- Hutton, G. & Meijer, E. (1996). Monadic Parser Combinators. PDF
- Leijen, D. & Meijer, E. (2001). Parsec: Direct Style Monadic Parser Combinators For The Real World.
- Geng, S. et al. (2024). Grammar-Constrained Decoding for Structured NLP Tasks without Finetuning. arXiv:2305.13971
- Dong, Y. et al. (2024). XGrammar: Flexible and Efficient Structured Generation Engine for LLMs. arXiv:2411.15100
- Parsimmon (TypeScript): https://github.com/jneen/parsimmon
- Arcsecond (TypeScript): https://github.com/francisrstokes/arcsecond
- Drizzle ORM: https://orm.drizzle.team/
- DSPy: https://dspy.ai/
- LangGraph: https://langchain-ai.github.io/langgraph/
Publicado también en Medium