wandres.dev
EL LENGUAJE: TIPOS · estáticos e inferidos

Tipos parametrizados: el hueco que hace genérico a un tipo

Los tipos de Elm que aparecen por todas partes no son tipos sino plantillas: List a, Maybe a, Result e v y Dict k v tienen huecos que hay que rellenar antes de que designen algo concreto. Esta lección explica el polimorfismo paramétrico desde su mecánica hasta su consecuencia más profunda. Se distingue el constructor de tipos del tipo terminado, se muestra por qué dos apariciones de la misma variable obligan al mismo tipo y qué información transmite eso al lector de una firma, y se define la parametricidad: el hecho de que una función genérica no puede inspeccionar aquello que no conoce, de donde se sigue que una firma suficientemente general determina casi por completo lo que la función puede hacer. Se practican los ejemplos concretos —map, filter, foldr, andThen— se explica por qué Msg como parámetro de Html y de Cmd es el mismo mecanismo aplicado a la arquitectura, y se cierra con el contraste entre lo paramétrico y lo restringido en un lenguaje sin clases de tipos.

⏱ 19 min

Después de tres lecciones usándolos sin nombrarlos, toca poner nombre a los huecos. Cuando escribes List a no estás nombrando un tipo, estás nombrando una plantilla de tipos: List por sí solo no designa nada que pueda existir en un programa, igual que una función sin argumentos aplicados no designa un valor. Solo cuando el hueco a se rellena con algo concreto —List Int, List Usuario, List (Maybe String)— aparece un tipo del que se pueden tener valores. Esa idea, el polimorfismo paramétrico, es la que permite que la biblioteca estándar tenga una sola función map en lugar de una por cada tipo de elemento. Pero su interés no acaba en la reutilización, que es lo obvio. Lo verdaderamente notable es lo contrario de lo que uno esperaría: cuanto menos sabe una función sobre sus datos, más sabemos nosotros sobre esa función. La generalidad no diluye la información del tipo, la concentra.

🎯 Al terminar esta lección sabrás
  • Distinguir un constructor de tipos como List o Maybe del tipo concreto que resulta de rellenar sus huecos.
  • Leer firmas polimórficas sabiendo qué obliga la repetición de una misma variable de tipo.
  • Comprender la parametricidad: qué puede y qué no puede hacer una función con un valor de tipo desconocido.
  • Reconocer el mismo mecanismo en los tipos de la arquitectura, como Html msg y Cmd msg.

Un tipo con un hueco todavía no es un tipo

La distinción sintáctica es mínima y la conceptual es enorme. Int es un tipo: existen valores suyos. List no es un tipo: no existe ningún valor cuyo tipo sea List, del mismo modo que no existe ningún número que sea la suma. List es una función a nivel de tipos, que recibe un tipo y devuelve un tipo. Se le llama constructor de tipos, y como cualquier función, hay que aplicarlo para obtener algo concreto.

-- Declarar un tipo parametrizado: la minuscula es el hueco
type Maybe a
    = Just a
    | Nothing

type Result e v
    = Ok v
    | Err e

-- Aplicar el constructor produce tipos concretos y distintos
edades : List Int
nombres : List String
matriz : List (List Float)
respuesta : Result String Usuario

Fíjate en Just. Es una función corriente cuya firma es a -> Maybe a: recibe un valor cualquiera y lo envuelve. Y en Nothing, que no recibe nada y cuyo tipo es directamente Maybe a, válido para cualquier a, porque no contiene nada que pudiera contradecir esa promesa. Los constructores de las variantes son valores del lenguaje, mientras que los constructores de tipos viven un piso más arriba, en el nivel de los tipos. Elm mantiene esos dos pisos rigurosamente separados, y por eso no hay ambigüedad aunque los nombres se parezcan.

ℹ️
La misma letra significa el mismo tipo

Dentro de una firma, dos apariciones de a no son dos comodines independientes: son la misma incógnita, y el compilador exige que se rellenen con el mismo tipo. Por eso a -> a describe funciones que devuelven algo del tipo que recibieron, mientras que a -> b permite cambiar de tipo. Esa disciplina de repetición es lo que convierte una firma polimórfica en información precisa en lugar de en una renuncia a informar.

Lo que una firma genérica te está prohibiendo

Aquí llega el giro que separa entender el polimorfismo de haberlo entendido de verdad. Cuando una función recibe un valor de tipo a, no sabe nada sobre él: no puede sumarlo, no puede compararlo, no puede convertirlo a texto ni preguntar de qué tipo es. En Elm no existe la reflexión, así que esa ignorancia es absoluta y no hay forma de eludirla. Lo único que la función puede hacer con un valor de tipo desconocido es lo mismo que harías tú con un paquete cerrado: guardarlo, duplicarlo, descartarlo, moverlo de sitio o entregárselo a otra función que sí sepa qué hacer con él.

-- Solo hay una funcion posible con esta firma, y es la identidad
misterio : a -> a

-- Con esta hay exactamente dos: devolver siempre el primero
-- o devolver siempre el segundo
eleccion : a -> a -> a

-- Y esta es imposible de escribir sin trampas: de donde sacarias un b
imposible : a -> b

Esa observación tiene nombre técnico, parametricidad, y consecuencias sorprendentemente fuertes. La firma a -> a no admite más implementación total que devolver el argumento intacto: no hay ningún otro valor de tipo a disponible en el universo de esa función, porque no puede fabricarlo. La firma List a -> List a restringe a reordenar, repetir o descartar elementos, y jamás a inventarlos o modificarlos. Y map : (a -> b) -> List a -> List b garantiza que cada elemento de la salida procede de aplicar la función recibida a algún elemento de la entrada, porque no existe otra manera de producir un b. Leer una firma genérica es, entonces, leer una lista de imposibilidades, y esa lista suele ser más informativa que el nombre de la función.

flowchart TD
F[Firma con variable de tipo] --> P[La funcion no conoce el tipo]
P --> N1[No puede inspeccionarlo]
P --> N2[No puede fabricarlo]
P --> N3[No puede compararlo]
N1 --> G[Conjunto de implementaciones posibles muy pequeno]
N2 --> G
N3 --> G
G --> S[La firma casi determina el comportamiento]
style G fill:#89b4fa,color:#11111b
style S fill:#a6e3a1,color:#11111b
💡
Generaliza para saber más, no solo para reutilizar más

De aquí sale una heurística de diseño concreta: si una función no necesita mirar dentro de sus datos, dale un tipo genérico aunque solo la uses con uno concreto. Cambiar List Usuario -> List Usuario por List a -> List a no mejora la reutilización si nunca la reutilizas, pero sí demuestra al lector, y al compilador, que esa función no puede tocar el contenido de los elementos. La firma se convierte en una prueba de lo que la función no hace, y esas pruebas son las que hacen barato leer código ajeno.

Los ejemplos concretos, uno a uno

Vale la pena recorrer las firmas polimórficas que aparecen a diario y traducir cada una a lenguaje llano, porque el hábito de hacerlo mentalmente es lo que acaba volviendo transparente la biblioteca estándar entera.

-- Transformar cada elemento: la salida se fabrica solo con la funcion dada
List.map : (a -> b) -> List a -> List b

-- Quedarse con algunos: el tipo no cambia, luego no puede modificarlos
List.filter : (a -> Bool) -> List a -> List a

-- Reducir a un solo valor acumulando de derecha a izquierda
List.foldr : (a -> b -> b) -> b -> List a -> b

-- Aplicar dentro del envoltorio sin abrirlo a mano
Maybe.map : (a -> b) -> Maybe a -> Maybe b

-- Encadenar operaciones que a su vez pueden faltar
Maybe.andThen : (a -> Maybe b) -> Maybe a -> Maybe b

-- Dos huecos independientes: el error y el valor viajan por separado
Result.map : (a -> b) -> Result e a -> Result e b

Compara las dos últimas parejas, porque su diferencia es la que más cuesta al principio. Maybe.map recibe una función que siempre devuelve un valor y la aplica dentro del envoltorio; si le dieras una función que a su vez puede fallar, obtendrías un Maybe dentro de otro Maybe, un anidamiento que no quieres. Para eso está andThen, cuya función recibida ya devuelve algo envuelto y cuyo trabajo es aplanar el resultado. Las firmas lo dicen todo sin necesidad de explicación en prosa: basta mirar dónde aparece el envoltorio en el tipo de la función que se recibe.

📐

Constructor de tipos

List, Maybe o Dict no son tipos sino plantillas. Reciben tipos y devuelven tipos, y solo aplicados designan algo de lo que puede haber valores.

🔒

Parametricidad

Sobre un valor de tipo desconocido solo se puede guardar, mover o descartar. Esa incapacidad es lo que hace que la firma prediga el comportamiento.

🔁

map y andThen

Aplicar dentro del envoltorio frente a encadenar produciendo otro envoltorio. La diferencia está escrita en el tipo de la función que reciben.

✉️

Html msg y Cmd msg

El mismo mecanismo sosteniendo la arquitectura: la vista y los comandos son genéricos sobre el mensaje que producirán al llegar al bucle.

El polimorfismo que sostiene la arquitectura

Los tipos parametrizados no son una utilidad de la biblioteca de colecciones: son el andamio de la Arquitectura Elm. Html msg significa un fragmento de vista que, cuando el usuario interactúe con él, producirá mensajes de tipo msg. Cmd msg significa un efecto que, cuando el runtime lo ejecute, devolverá su resultado como un msg. Que el parámetro esté ahí es lo que permite escribir componentes de vista reutilizables, ajenos al mensaje concreto de la aplicación que los use, y luego adaptarlos con Html.map al conectarlos a un módulo mayor.

-- Un boton generico: sirve para cualquier aplicacion
boton : String -> msg -> Html msg
boton texto alPulsar =
    button [ onClick alPulsar ] [ text texto ]

-- Traducir los mensajes de un submodulo al del padre
Html.map : (a -> b) -> Html a -> Html b
Cmd.map : (a -> b) -> Cmd a -> Cmd b

Ese Html.map es el mismo map de siempre, con la misma forma y la misma garantía, aplicado a un constructor de tipos distinto. Cuando compones un submódulo dentro de una aplicación mayor, no necesitas un mecanismo especial de comunicación entre componentes: envuelves sus mensajes en una variante del mensaje del padre y traduces la vista con Html.map. Toda la composición modular de Elm descansa en que estos tipos tengan un hueco.

Cuanto menos sabe la función, más sabes tú

La intuición corriente sobre lo genérico es que es una renuncia. Al escribir a en vez de Usuario parece que estamos borrando información, aflojando el tipo, aceptando saber menos a cambio de que la función sirva en más sitios; lo genérico se percibe como lo vago. La parametricidad demuestra que esa intuición está exactamente invertida, y la demostración es de una elegancia difícil de olvidar. Un tipo concreto en una firma es una licencia: si una función recibe un Usuario, puede leer su edad, comparar su nombre, imprimirlo, ramificar según sus campos, y por tanto el espacio de comportamientos compatibles con esa firma es astronómico y la firma no te dice casi nada sobre lo que ocurre dentro. Una variable de tipo es lo contrario de una licencia: es una prohibición. Al escribir a le estás quitando a la función toda capacidad de mirar el valor, y como en Elm no hay reflexión ni comodines ni castings, esa prohibición no admite excepciones. El resultado es que el conjunto de implementaciones posibles se estrecha hasta volverse minúsculo, y en casos límite hasta contener un solo elemento: no existe más función total de tipo a -> a que la identidad, y no hay que leer el cuerpo para saberlo. De ahí que se hable de teoremas gratis, propiedades del comportamiento que se demuestran a partir del tipo y sin mirar el código. Esto reordena por completo el sentido de una firma: deja de ser una etiqueta que describe qué datos entran y salen y pasa a ser un cerco que delimita qué es capaz de hacer el programa. Y de ahí sale la disciplina de diseño más valiosa de la programación funcional tipada, que consiste en pedir siempre lo mínimo. Si una función solo necesita saber que hay elementos, que no sepa cuáles; si solo necesita ordenar, que no pueda transformar. Cada capacidad que no concedes es una familia entera de errores que ni siquiera puede escribirse, y es a la vez una promesa que el lector recibe sin coste. Programar con tipos parametrizados no es programar de forma más abstracta; es programar concediendo menos poder y obteniendo, a cambio, más certeza.

⚔️ Deduce comportamientos leyendo solo los tipos
  1. Enumera todas las funciones totales posibles con la firma a -> a -> a y justifica por qué no puede haber más.
  2. Explica por qué imposible : a -> b no admite ninguna implementación total, y qué tendría que ofrecer el lenguaje para que la admitiera.
  3. Razona a partir del tipo de List.filter por qué esa función no puede modificar los elementos que conserva.
  4. Escribe una función genérica sobre List a que solo reordene, y comprueba que el compilador no te deja mirar dentro de los elementos.
  5. Diferencia con un ejemplo propio Maybe.map de Maybe.andThen fijándote únicamente en el tipo de la función que cada uno recibe.
  6. Define un botón reutilizable con firma String -> msg -> Html msg y úsalo desde dos módulos con mensajes distintos; explica el papel de Html.map al componerlos.