Capítulo 07: Hindley-Milner y yo
¿Cuál es tu tipo?
Si eres nuevo en el mundo funcional, pronto te encontrarás hasta las rodillas en firmas de tipos. Los tipos son el metalenguaje que permite a personas de distintos contextos comunicarse de manera sucinta y efectiva. En su mayoría se escriben con el sistema denominado 'Hindley-Milner', que examinaremos juntos en este capítulo.
Al trabajar con funciones puras, las firmas de tipos poseen un poder expresivo incomparable con el lenguaje natural. Estos especificaciones susurran al oído los secretos íntimos de una función. En una sola línea compacta, revelan comportamiento e intención. Podemos derivar 'teoremas gratuitos' de ellas. Los tipos pueden inferirse automáticamente sin necesidad de anotaciones explícitas. Permiten ajustar precisión o mantener generalidad abstracta. Además de útiles para verificaciones en tiempo de compilación, resultan ser la documentación más eficaz. Las firmas de tipo juegan así un papel crucial en programación funcional - mucho mayor de lo inicialmente esperado.
JavaScript es dinámico, pero eso no implica evitar totalmente los tipos. Seguimos manejando cadenas, números, booleanos, etc. La diferencia radica en la ausencia de integración nativa, por lo que retenemos esta información mentalmente. Al usar firmas como documentación, podemos emplear comentarios para cubrir esta necesidad.
Existen herramientas de verificación como Flow o el lenguaje tipado TypeScript. El objetivo de este libro es proveer herramientas para codificar funcionalmente, manteniendo el sistema estándar de FP.
Historias de lo críptico
Desde libros matemáticos hasta código fuente, el sistema Hindley-Milner se manifiesta en diversos contextos. Su simplicidad requiere explicación concisa y práctica para dominar este metalenguaje.
// capitalize :: String -> String
const capitalize = s => toUpperCase(head(s)) + toLowerCase(tail(s));
capitalize('smurf'); // 'Smurf'Aquí, capitalize recibe String y devuelve String. No te preocupes por la implementación; lo relevante es la firma de tipos.
En HM, las funciones se escriben a -> b con a y b como variables de tipo. La firma indica entonces: 'función de String a String', es decir, entrada y salida cadena.
Analicemos más firmas funcionales:
// strLength :: String -> Number
const strLength = s => s.length;
// join :: String -> [String] -> String
const join = curry((what, xs) => xs.join(what));
// match :: Regex -> String -> [String]
const match = curry((reg, s) => s.match(reg));
// replace :: Regex -> String -> String -> String
const replace = curry((reg, sub, s) => s.replace(reg, sub));strLength sigue el mismo patrón: transforma String en Number.
Casos como match pueden resultar confusos inicialmente. Interpretando el último tipo como valor de retorno: recibe Regex y String, entrega [String]. Pero aquí ocurre algo interesante que amerita explicación.
Al agrupar con paréntesis, match revela su estructura real:
// match :: Regex -> (String -> [String])
const match = curry((reg, s) => s.match(reg));Agrupando la última parte, se observa una función que toma Regex y devuelve String -> [String]. Gracias a la currificación, esto se hace evidente: al proveer Regex obtenemos función esperando String. No es necesario conceptualizarlo así, pero ayuda comprender la relación parámetros-retorno.
// match :: Regex -> (String -> [String])
// onHoliday :: String -> [String]
const onHoliday = match(/holiday/ig);Cada argumento consume un tipo del inicio de la firma. onHoliday representa match con Regex predefinido.
// replace :: Regex -> (String -> (String -> String))
const replace = curry((reg, sub, s) => s.replace(reg, sub));Como muestra replace con todos los paréntesis, la notación puede volverse redundante. Al omitirlos, interpretamos directamente: recibe Regex, dos String y entrega String.
Últimas observaciones:
// id :: a -> a
const id = x => x;
// map :: (a -> b) -> [a] -> [b]
const map = curry((f, xs) => xs.map(f));La función id acepta tipo a cualquiera y devuelve a. Usamos variables tipográficas como en código. Convenciones como a/b son arbitrarias pero deben mantener consistencia: a -> a exige tipos idénticos, mientras a -> b permite diferencia.
map usa variables tipográficas con b potencialmente distinto de a. Su firma indica transformación de array de a a array de b mediante función a->b.
Esta firma muestra belleza descriptiva: describe funcionalidad literalmente. Dada función a->b y array de a, produce array de b. La única implementación sensata aplica la función a cada elemento.
El razonamiento tipográfico es habilidad clave en FP. Facilita comprensión de documentación técnica y revela funcionalidad mediante inspección de firmas. Requiere práctica, pero desbloquea información valiosa sin lectura exhaustiva.
Más ejemplos para practicar decodificación:
// head :: [a] -> a
const head = xs => xs[0];
// filter :: (a -> Bool) -> [a] -> [a]
const filter = curry((f, xs) => xs.filter(f));
// reduce :: ((b, a) -> b) -> b -> [a] -> b
const reduce = curry((f, x, xs) => xs.reduce(f, x));reduce posee la firma más expresiva, aunque desafiante. Para curiosos: explicaré su funcionamiento alternativamente, aunque el autoanálisis resulta más instructivo.
Analizando la firma, el primer argumento (función que toma b y a para producir b) opera sobre los a del array y b inicial. El resultado final es el último b generado. Esto coincide con el comportamiento conocido de reduce.
Limitando posibilidades
Al introducir variables tipográficas, emerge la parametricidad: funciones actúan uniformemente sobre todos los tipos. Ejemplo con head:
// head :: [a] -> aLa firma [a] -> a restringe implementación a operaciones genéricas sobre arrays. Al desconocer a, solo puede seleccionar primer/último elemento aleatoriamente. El nombre head nos da una pista sobre el primer elemento.
Otro caso: reverse:
// reverse :: [a] -> [a]Su firma [a] -> [a] excluye operaciones específicas de a. No puede ordenar (requeriría comparación) ni alterar elementos. Solo reordenar uniformemente, usualmente invertir secuencia.
Esta restricción posibilita búsquedas eficientes en motores como Hoogle. La información encapsulada en firmas es sumamente potente.
Teoremas gratuitos
Más allá de implementaciones, esta lógica genera teoremas gratuitos. Ejemplos extraídos del artículo de Wadler:
// head :: [a] -> a
compose(f, head) === compose(head, map(f));
// filter :: (a -> Bool) -> [a] -> [a]
compose(map(f), filter(compose(p, f))) === compose(filter(p), map(f));Estos teoremas emergen directamente de los tipos. El primero establece que aplicar f al head de array equivale (y es más eficiente que) mapear f y luego obtener head.
Aunque intuitivo, este principio requiere formalización matemática para implementación automatizada. Las matemáticas estructuran la intuición en lógica computacional.
El teorema de filter indica componer predicado p con función f, equivalente a mapear antes de filtrar. La firma garantiza preservación de a durante el filtrado.
Estos patrones aplican a cualquier firma polimórfica. En JavaScript existen herramientas para declarar reglas de reescritura, explotando estas propiedades mediante composición funcional.
Restricciones tipográficas
Podemos limitar tipos a interfaces específicas:
// sort :: Ord a => [a] -> [a]La expresión Ord a => indica que a debe implementar interfaz Ord (tipos ordenables). Esto restringe dominio y clarifica el funcionamiento de sort.
// assertEqual :: (Eq a, Show a) => a -> a -> AssertionAquí, Eq y Show exigen capacidades de comparación y visualización de a.
Veremos más ejemplos de restricciones en próximos capítulos.
En resumen
Las firmas Hindley-Milner son ubicuas en FP. Su dominio requiere tiempo, pero permiten comprensión mediante inspección tipográfica. A partir de ahora, incluiremos firmas en todo código.