wandres.dev
ROUTING Y SPA · Browser.application

Url.Parser: de una cadena de direcciones a un tipo del dominio

Una URL es una cadena de texto, es decir, el peor tipo de dato imaginable para tomar decisiones: admite infinitas formas, no dice nada sobre su contenido y obliga a comprobar en cada uso lo mismo que ya se comprobó antes. La biblioteca Url.Parser existe para que esa cadena viva lo menos posible dentro del programa. Esta lección construye un enrutador completo desde los primeros principios: por qué el tipo Route es un union type del dominio y no un alias de String, cómo se compone un parser a partir de piezas mínimas con los operadores de segmento y de query, por qué el parser recibe el constructor del Route y le va aplicando los fragmentos que extrae, qué significa que el resultado sea un Maybe y por qué esa ausencia es exactamente el caso de página no encontrada, cómo se leen los parámetros de consulta con sus propios combinadores y por qué siempre devuelven valores opcionales, y qué disciplina impone el orden de las alternativas dentro de oneOf. La tesis es que el enrutado no es una tabla de expresiones regulares ni un fichero de configuración, sino un acto de parsing con un tipo de llegada que forma parte del modelo del dominio.

⏱ 17 min

La mayor parte de los enrutadores que has usado comparten un vicio de origen: tratan la dirección como texto de principio a fin. Se declara una tabla de patrones con comodines, se casa la dirección con el primero que encaje, se extraen unos fragmentos que llegan como cadenas dentro de un objeto sin forma, y a partir de ahí cada pantalla se defiende como puede convirtiendo, comprobando y suponiendo. El problema no es que sea incómodo, es que aplaza indefinidamente la única pregunta que importa: qué estados puede tener esta aplicación. Un enrutador basado en cadenas nunca responde a eso, porque su vocabulario son las direcciones y no los estados, y porque una cadena admite infinitas formas de las cuales solo un puñado significan algo. Url.Parser invierte el planteamiento por completo. Su trabajo no es casar patrones sino traducir: coge una cadena venida del exterior y produce, si puede, un valor de un tipo que tú has definido enumerando exactamente las pantallas que existen. Lo que llega al resto del programa ya no es texto sino un valor del dominio, y la frontera donde la ambigüedad se resuelve queda reducida a una sola función.

🎯 Al terminar esta lección sabrás
  • Definir un tipo Route que enumere los estados direccionables de la aplicación y justificar por qué no puede ser un alias de texto.
  • Componer parsers a partir de piezas mínimas con los operadores de segmento y aplicar el constructor de Route a los fragmentos extraídos.
  • Leer parámetros de consulta con sus combinadores y explicar por qué siempre producen valores opcionales.
  • Interpretar el Maybe que devuelve el parser como el caso de página no encontrada y ordenar las alternativas de oneOf con criterio.

El tipo de llegada se declara antes que el parser

Todo enrutado bien hecho empieza por una pregunta que no menciona direcciones: cuáles son las pantallas de esta aplicación y qué datos necesita cada una para identificarse. La respuesta es un union type, y conviene escribirlo antes de tocar la biblioteca, porque es la especificación contra la que se escribirá el parser. Cada constructor es un estado direccionable y sus argumentos son exactamente los datos que la dirección aporta: un identificador numérico, un término de búsqueda, un número de página. Nada más, porque la URL no transporta el contenido de la pantalla sino su identidad.

type Route
    = Inicio
    | Articulos
    | Articulo Int
    | Buscar (Maybe String) Int
    | Perfil String

Repara en que este tipo se puede leer sin saber nada de rutas ni de navegación. Dice que hay cinco pantallas, que la de artículo se identifica con un entero, que la de búsqueda admite un término que puede faltar y una página, y que la de perfil se identifica con un nombre. Es un vocabulario del dominio, no una descripción de direcciones, y esa es precisamente la razón por la que el resto del programa podrá trabajar con él sin volver a mirar la barra del navegador. La dirección solo vuelve a aparecer en dos lugares del proyecto: cuando entra, para producir un Route, y cuando sale, para construir un enlace a partir de un Route.

Componer parsers como se componen funciones

Un parser en esta biblioteca tiene el tipo Parser a b y se lee mejor pensando que consume segmentos de la ruta y va aplicando lo que extrae a una función que se le entregó al principio. Las piezas mínimas son pocas: s casa un segmento literal, int y string consumen un segmento y capturan su valor, top casa el final de la ruta. El operador </> encadena piezas en secuencia, y map conecta esa secuencia con el constructor del Route que corresponda. Cuando escribes map Articulo (s "articulos" </> int), estás diciendo que la ruta formada por el literal seguido de un entero produce el constructor aplicado a ese entero, y el compilador comprueba que el número y el tipo de las capturas coinciden exactamente con lo que el constructor pide.

import Url.Parser as P exposing ((</>), (<?>))
import Url.Parser.Query as Q


parser : P.Parser (Route -> a) a
parser =
    P.oneOf
        [ P.map Inicio P.top
        , P.map Articulos (P.s "articulos")
        , P.map Articulo (P.s "articulos" </> P.int)
        , P.map Perfil (P.s "perfil" </> P.string)
        , P.map Buscar
            (P.s "buscar"
                <?> Q.string "q"
                <?> Q.map (Maybe.withDefault 1) (Q.int "pagina")
            )
        ]


parse : Url -> Route
parse url =
    Maybe.withDefault NoEncontrado (P.parse parser url)
🧱

Piezas mínimas

s para un literal, int y string para capturar un segmento, top para el final. Todo lo demás se construye componiendo estas cuatro.

⛓️

Secuencia

El operador </> encadena. La lectura de izquierda a derecha coincide con el recorrido de la ruta, así que el parser se parece a la dirección que describe.

🎯

map y el constructor

map entrega el constructor y el parser le va aplicando las capturas. Si sobran o faltan argumentos, no compila: la ruta y el tipo se validan a la vez.

🔀

oneOf

Prueba alternativas en orden y devuelve la primera que consuma la ruta entera. El orden importa cuando dos patrones pueden solaparse.

ℹ️
Por qué el tipo del parser lleva una flecha dentro

La firma Parser (Route -> a) a desconcierta al principio y merece una explicación, porque entenderla desactiva la mitad de los errores de compilación de este módulo. Un parser no devuelve un valor: transporta una función pendiente de argumentos. Al escribir map Articulo, el parser arranca con una función que espera un entero, y cuando int captura un segmento se lo aplica, dejando el Route ya construido. Encadenar con </> es, en el fondo, encadenar aplicaciones parciales. De ahí se sigue que el compilador detecte de un tirón que una ruta con dos capturas alimenta un constructor de un solo argumento: no hay que probar la aplicación para descubrirlo, es un desajuste de tipos como cualquier otro.

Los parámetros de consulta y su opcionalidad irreducible

La cadena de consulta se trata con un módulo aparte y con un operador propio, <?>, y esa separación es deliberada porque los parámetros no son segmentos: no tienen orden, pueden faltar, pueden repetirse y pueden venir con valores que no encajan con lo esperado. Los combinadores de consulta reflejan esa realidad devolviendo siempre valores opcionales. Q.string "q" produce un valor que puede estar ausente, y Q.int "pagina" produce un valor ausente tanto si el parámetro no venía como si venía con algo que no es un número. La biblioteca no ofrece ninguna variante que garantice presencia, y esa ausencia de opción es intencionada: nada impide a un usuario borrar un parámetro de la barra de direcciones antes de pulsar la tecla de entrada.

💡
Decide el valor por defecto en el parser, no en la vista

Cuando un parámetro tiene un valor razonable por defecto —la primera página, el orden habitual, el filtro vacío—, aplícalo dentro del parser con Q.map y deja que el constructor del Route reciba ya un valor concreto. La alternativa, arrastrar el valor opcional hasta la vista y resolverlo allí, obliga a repetir la misma decisión en cada punto de uso y abre la puerta a que dos sitios elijan defectos distintos. Como regla: si la ausencia del parámetro significa un valor concreto, resuélvela en la frontera; si significa un estado distinto de la pantalla, consérvala en el tipo y trátala en el case.

El orden dentro de oneOf merece un aviso porque produce fallos silenciosos. Las alternativas se prueban en secuencia y gana la primera que consuma la ruta completa, de modo que si dos patrones pueden casar con la misma dirección, el segundo nunca se alcanzará. El caso clásico son un literal y una captura al mismo nivel: si colocas la captura de un nombre de perfil antes que el literal de una pantalla especial que vive en la misma posición, esa pantalla se volverá inalcanzable y el enlace llevará a un perfil inexistente. La regla es sencilla y basta con aplicarla siempre: lo específico antes que lo general.

flowchart LR
U[Url del navegador] --> P[Url Parser]
P --> S[Segmentos con s int string]
P --> Q[Query con string e int]
S --> C[Constructor de Route]
Q --> C
C --> M[Maybe Route]
M --> R[Route del dominio]
M --> N[NoEncontrado]
style P fill:#89b4fa,color:#11111b
style C fill:#f9e2af,color:#11111b
style R fill:#a6e3a1,color:#11111b

La ausencia como página no encontrada

P.parse devuelve un valor opcional, y esa firma es una de las decisiones mejor calibradas de la biblioteca. Una dirección que nadie reconoce no es un error del programa ni una excepción ni una condición extraordinaria: es simplemente una dirección que no corresponde a ninguna pantalla, algo que ocurre todos los días con enlaces viejos, direcciones tecleadas a mano y buscadores que guardan rutas que ya no existen. Representarla como ausencia y resolverla con un constructor NoEncontrado del propio Route la convierte en un estado normal del dominio, con su vista, su título y su lugar en el case exhaustivo de la pantalla.

Conviene además que la conversión de la ausencia ocurra en un solo sitio, dentro de la función parse del módulo de rutas, y que el resto del programa nunca vea el valor opcional. Así la firma que exporta el módulo es de Url a Route, total y sin sorpresas, y el compilador puede garantizar que cada pantalla se ha considerado.

El módulo de rutas debe exportar además la función inversa, de Route a texto, y este es el detalle que separa un enrutador correcto de uno que solo lo parece. Si la entrada está tipada por el parser pero la salida se escribe a mano en cada enlace de la vista, la mitad del beneficio se pierde: cambiar la forma de una ruta seguirá compilando y el fallo aparecerá como una página no encontrada sin ninguna pista sobre su procedencia. Con las dos funciones enfrentadas en el mismo fichero, la simetría queda a la vista, un cambio en una obliga a revisar la otra, y ninguna dirección de la aplicación puede existir sin corresponder a un valor del dominio.

toString : Route -> String
toString ruta =
    case ruta of
        Inicio ->
            "/"

        Articulos ->
            "/articulos"

        Articulo id ->
            "/articulos/" ++ String.fromInt id

        Perfil nombre ->
            "/perfil/" ++ nombre

        Buscar termino pagina ->
            "/buscar?q="
                ++ Maybe.withDefault "" termino
                ++ "&pagina="
                ++ String.fromInt pagina

        NoEncontrado ->
            "/404"
Enrutar es parsear, y parsear es estrechar el tipo en la frontera

Detrás de este módulo hay un principio de diseño que trasciende con mucho el enrutado y que conviene enunciar en su forma general: analizar en lugar de validar. Validar consiste en comprobar una condición sobre un dato y dejarlo tal como estaba, de modo que la comprobación no deja huella en el tipo y todo el código posterior sigue sin saber nada; por eso los programas escritos con validaciones repiten la misma comprobación una y otra vez, y por eso basta olvidarla una sola vez para que el fallo aparezca a diez capas de distancia del sitio donde el dato entró. Analizar consiste en consumir el dato ancho y producir uno estrecho que solo puede existir si la condición se cumplió, de manera que la comprobación queda registrada en el tipo y no puede repetirse ni olvidarse. Un enrutador de cadenas valida: casa un patrón, te devuelve texto y te deja con el mismo problema que tenías. Url.Parser analiza: consume texto y produce un Route, un valor que por construcción es una de las pantallas que existen, con los datos que esa pantalla necesita y con los tipos que esos datos deben tener. La diferencia se paga en el sitio donde se cobra el mantenimiento. Cuando añades una pantalla al union type, el compilador enumera todos los lugares donde falta tratarla, y esa lista es exhaustiva porque el tipo lo es. Cuando quitas una, el compilador señala los enlaces que apuntaban a ella. Cuando cambias un identificador de entero a otra cosa, el compilador recorre la aplicación entera contigo. Nada de eso es posible mientras la ruta siga siendo una cadena, porque una cadena no tiene forma y por tanto no tiene nada que romperse cuando el dominio cambia. La barra de direcciones es la entrada de datos externa más expuesta que tiene una aplicación web —cualquiera puede escribir en ella lo que quiera— y merece exactamente la misma disciplina que se aplica al JSON de un servidor: no dejar que el texto entre, sino traducirlo en la puerta a un valor que ya no pueda mentir.

⚔️ Traduce direcciones a estados del dominio
  1. Escribe el tipo Route de una aplicación que conozcas sin mirar sus direcciones, y comprueba después cuántos de sus estados no eran direccionables.
  2. Construye el parser correspondiente y provoca a propósito un desajuste entre las capturas y los argumentos del constructor; lee el error y explica qué te está diciendo la flecha del tipo.
  3. Añade una pantalla de búsqueda con término opcional y número de página con valor por defecto resuelto dentro del parser.
  4. Coloca una captura de segmento antes de un literal del mismo nivel en oneOf y observa cómo una pantalla se vuelve inalcanzable; corrígelo y enuncia la regla.
  5. Escribe la función inversa de Route a texto y sustituye todos los destinos escritos a mano de tu vista por llamadas a ella.
  6. Añade un constructor nuevo al Route y anota cuántos errores de compilación aparecen; argumenta qué habría ocurrido con un enrutador basado en cadenas.