Parse URLs with elm/url
2021-05-18

Previously, I wrote about using Browser.application, which allows you to handle URLs and provide routing via the History API. In that post I used a ready-made URL parser from the Elm guide:

type alias DocsRoute =
  (String, Maybe String)

docsParser : Parser (DocsRoute -> a) a
docsParser =
  map Tuple.pair (string </> fragment identity)

-- /Basics     ==>  Just ("Basics", Nothing)
-- /Maybe      ==>  Just ("Maybe", Nothing)
-- /List       ==>  Just ("List", Nothing)
-- /List#map   ==>  Just ("List", Just "map")
-- /List#      ==>  Just ("List", Just "")
-- /List/map   ==>  Nothing
-- /           ==>  Nothing

In this post, I’d like to show how to write your own URL parser using the Url.Parser and Url.Parser.Query modules from the elm/url package.

Why parse the URL?

Since you can just store the relevant state such as the current page in the model, what’s the point of managing such state via the URL?

Parsing the URL allows you to do a couple of things:

  • Allow the user to link to specific states of your SPA (e.g. particular pages), by receiving and parsing the URL in the init function and rendering the initial state of the app accordingly.
  • Support the browser back and forward buttons, providing better navigation to the user.

Basic routing

The simplest scenario is when we have a bunch of pages, for example /home, /about, /contact.

The simplest parser imaginable just takes the URL fragment after / and stores it in the model:

type alias Route =
    String

type alias Model =
    { navKey : Nav.Key
    , route : Maybe Route
    }

(We have to use Maybe Route because parsing will fail for any URLs that cannot be successfully parsed.)

The following code shows a complete application scaffold that displays the route on the page (note that you can’t run this in Ellie as it doesn’t support Browser.application):

module Main exposing (main)

import Browser exposing (Document, UrlRequest(..))
import Browser.Navigation as Nav
import Html exposing (..)
import Url exposing (Url)
import Url.Parser exposing ((</>))


type Msg
    = ChangeUrl Url
    | ClickLink UrlRequest


type alias Route =
    String


type alias Model =
    { navKey : Nav.Key
    , route : Maybe Route
    }


urlParser : Url.Parser.Parser (Route -> a) a
urlParser =
    Url.Parser.string


init : () -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url navKey =
    ( { navKey = navKey, route = Url.Parser.parse urlParser url }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ChangeUrl url ->
            ( { model | route = Url.Parser.parse urlParser url }, Cmd.none )

        ClickLink urlRequest ->
            case urlRequest of
                Internal url ->
                    ( model, Nav.pushUrl model.navKey <| Url.toString url )

                External url ->
                    ( model, Nav.load url )


view : Model -> Document Msg
view model =
    let
        title =
            case model.route of
                Just route ->
                    route

                Nothing ->
                    "Invalid route"
    in
    { title = "URL handling example"
    , body =
        [ h2 [] [ text title ]
        ]
    }


main : Program () Model Msg
main =
    Browser.application
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        , onUrlRequest = ClickLink
        , onUrlChange = ChangeUrl
        }

In the above code snippet, we have urlParser which is defined to be Url.Parser.string. Url.Parser.string parses a single segment of the path portion of the URL as a string (ie returns it as-is).

Just like with JSON decoders, urlParser provides a description of how we want URLs to be parsed. We then pass urlParser to Url.Parser.parse in init and update to actually parse the current URL and store the resulting route in the model.

What if we request the index page (/)? We’ll see “Invalid route”. The index route doesn’t have anything in the path portion of the URL, so we can’t handle it with Url.Parser.string. Instead, we can use Url.Parser.top, a parser that doesn’t consume any path segments. Now we need to join up two different parsers into a single combined parser:

            +- Url.Parser.top ==> "/"
urlParser <-|
            +- Url.Parser.string ==> "/about" and "/contact"

Parsers are combined with Url.Parser.oneOf:

urlParser : Url.Parser.Parser (Route -> a) a
urlParser =
    Url.Parser.oneOf
        [ Url.Parser.map "home" <| Url.Parser.top
        , Url.Parser.string
        ]

If there are multiple parsers that match a URL, the first one in the list will succeed.

There is another new function in the snippet above: Url.Parser.map. Just like Maybe.map, it allows us to transform the value inside the parser. In this case, we have to match types with the string parser, so we pass the string “home” to map.

With this parser, the page will display “home” when we navigate to the root of the site, and otherwise it will display the path segment like “about” or “contact”.

You’ve probably noticed a code smell: storing the route as a string isn’t helping us ensure that we’ve handled all valid (and invalid) routes sensibly. Since we have a finite set pages, it makes a lot of sense to change Route from a string to a custom type:

type Route 
    = Home 
    | About 
    | Contact 

urlParser : Url.Parser.Parser (Route -> a) a
urlParser =
    Url.Parser.oneOf
        [ Url.Parser.map Home <| Url.Parser.top
        , Url.Parser.map About <| Url.Parser.s "about"
        , Url.Parser.map Contact <| Url.Parser.s "contact"
        ]

There are two changes in the parser:

  • For the top parser, we’re mapping the value to Home instead of a string
  • Instead of Url.Parser.string, we’re using Url.Parser.s which parses a fixed value in a path segment. We then map each of the fixed entries to Route values.

The view function needs to change correspondingly:

view : Model -> Document Msg
view model =
    let
        title =
            case model.route of
                Just Home ->
                    "Home"

                Just About ->
                    "About us"

                Just Contact ->
                    "Get in touch"

                Nothing ->
                    "Invalid route"
    in
    { title = "URL handling example"
    , body =
        [ h2 [] [ text title ]
        ]
    }

Handling parametrised routes

Now what if our site also has a blog with post URLs like /blog/123? We can add another route to handle that:

type Route
    = Home
    | About
    | Contact
    | Blog Int


urlParser : Url.Parser.Parser (Route -> a) a
urlParser =
    Url.Parser.oneOf
        [ Url.Parser.map Home <| Url.Parser.top
        , Url.Parser.map About <| Url.Parser.s "about"
        , Url.Parser.map Contact <| Url.Parser.s "contact"
        , Url.Parser.map Blog <| Url.Parser.s "blog" </> Url.Parser.int
        ]

There’s another new thing here: to parse blog routes, we’re composing two parsers: one to parse the fixed segment “blog”, and another to parse an integer ID. Parsers are composed with the operator </> (which we imported with import Url.Parser exposing ((</>))).

The combined parser produces an integer, so we can map it to the Blog constructor which takes an Int.

Similarly, if we had a more complex blog route like /blog/<category>/<id>/<title-slug>, we could handle it with this parser:

Url.Parser.map Blog <| 
    Url.Parser.s "blog" </> Url.Parser.string </> Url.Parser.int </> Url.Parser.string

Finally, we could use Url.Parser.custom to do more work during URL parsing, such as converting the category segment into an integer ID:

let
    categories =
        Dict.fromList [ ( "news", 1 ), ( "products", 2 ), ( "recalls", 3 ) ]

    parseCategory =
        Url.Parser.custom "category" <|
            \segment ->
                Dict.get segment categories

in
Url.Parser.oneOf
    [ ...
    , Url.Parser.map Blog <| 
        Url.Parser.s "blog" </> parseCategory </> Url.Parser.int </> Url.Parser.string
    ]

The first argument of Url.Parser.custom is a name for the custom parser (it can be whatever you want), and the second is a function String -> Maybe a.

Handling query string parameters

To handle query string parameters, we need to add an extra import of the <?> operator, as well as the Url.Parser.Query module:

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

Suppose we have product pages with the product ID passed in as part of the path, as well as a color and size specified as query params, for example /products/123?color=blue&size=32. Assuming the Route type below, we can write a parser for this kind of route as follows:

type Route
    = Home
    | About
    | Contact
    | Blog String Int String -- category id title-slug
    | Product Int (Maybe String) (Maybe Int) -- id color size

urlParser : Url.Parser.Parser (Route -> a) a
urlParser =
        [ ...
     , Url.Parser.map Product <|
         Url.Parser.s "products"
             </> Url.Parser.int
             <?> Url.Parser.Query.string "color"
             <?> Url.Parser.Query.int "size"
     ]

Note the use of <?> instead of </> for the query params portion of the route.

Query params are always treated as optional, which forces us to pass color and size as Maybe values to the Product constructor. It also means that, for example, /product/123 will parse as Just (Product 123 Nothing Nothing) rather than Nothing, which complicates the case statement for the route somewhat.

If we have a limited set of colours, we can get more type safety by parsing the parameter to a custom colour type with the help of Url.Parser.Query.enum:

type Color
    = Blue
    | White
    | Black

urlParser : Url.Parser.Parser (Route -> a) a
urlParser =
    [ ...
    , Url.Parser.map Product <|
          Url.Parser.s "products"
              </> Url.Parser.int
              <?> (Url.Parser.Query.enum "color" <| 
                       Dict.fromList [ ( "blue", Blue ), ( "black", Black ), ( "white", White ) ]
                  )
              <?> Url.Parser.Query.int "size"
    ]

(The Product constructor would now take Maybe Color instead of Maybe String.)

For more complex parameter values, there is Url.Parser.Query.custom. For example, we could allow the size to be either an integer or one of the labels “S”, “M”, “L” which we would then convert into a number:

urlParser : Url.Parser.Parser (Route -> a) a
urlParser =
    let
        sizeToInt : List String -> Maybe Int
        sizeToInt sizes =
            sizes
                |> List.head
                |> Maybe.andThen
                    (\size ->
                        case size of
                            "S" ->
                                Just 32

                            "M" ->
                                Just 36

                            "L" ->
                                Just 40

                            _ ->
                                String.toInt size
                    )
    in
    Url.Parser.oneOf
        [ ...
        , Url.Parser.map Product <|
            Url.Parser.s "products"
                </> Url.Parser.int
                <?> (Url.Parser.Query.enum "color" <|
                        Dict.fromList [ ( "blue", Blue ), ( "black", Black ), ( "white", White ) ]
                    )
                <?> Url.Parser.Query.custom "size" sizeToInt
        ]

Url.Parser.Query.custom allows you to handle multiple appearances of the same parameter in the query string (like size=S&size=M) which is why sizeToInt takes a list of strings rather than a single string. In our case, multiple sizes don’t make sense, so we take the first size from the list. We first check if it’s one of the known characters that means a size, and if not, we try to treat the value as an integer by applying String.toInt.

Handling hash fragments

Hash fragments can be useful to provide in-page links, for example to sections of a document. For example, if we have a support page with several sections, we can add a route constructor for it like this:

type Route 
    ...
    | Support (Maybe String)

Then the (optional) string representing the section can be obtained from the URL fragment like this:

    Url.Parser.oneOf
        [ ...
        , Url.Parser.map Support <|
            Url.Parser.s "support"
                </> Url.Parser.fragment identity
            ]

Reusing parsers

Consider the typical scenario of allowing CRUD operations for different types of things that we store in our database. For instance, we might be able to create, view, and edit both customers and suppliers, so we need to handle the following routes:

/customers/create
/customers/view
/customers/edit

/suppliers/create
/suppliers/view
/suppliers/edit

Since route parsers are composable, we can reuse the repetitive part of the parsing, so we only need to add two lines to our parser:

    Url.Parser.oneOf
        [ ...
        , actionParser Customer "customers"
        , actionParser Supplier "suppliers"
        ]

The Route type needs new Customer and Supplier constructors:

type Action
    = Create
    | View
    | Edit

type Route
    ...
    | Customer Action
    | Supplier Action

Finally, the actionParser definition looks like this:

actionParser routeCtor path =
    let
        parseAction =
            Url.Parser.string
                |> Url.Parser.map
                    (\s ->
                        case s of
                            "create" ->
                                Create

                            "edit" ->
                                Edit

                            _ ->
                                View
                    )
    in
    Url.Parser.map routeCtor <| Url.Parser.s path </> parseAction

Handling different path prefixes

Sometimes, an application can be mounted at a different URL path depending on the environment. I encountered this situation with Elm Catalog: the public version lives at https://korban.net/elm/catalog, but the development version is simply served from localhost. The public path prefix could also change in the future if I reorganise the site.

So, to put it more generally, the public version could be served from a URL with a path prefix consisting of some number of segments a/b/c/, while the development version is served from the root path.

Theoretically, what I really need to do is ignore all the path segments until encountering a segment named either “packages” or “tools”, but I didn’t see a way to express that with UrlParser functions, so to handle this scenario, I decided to pass in the prefix via the flags. The string is “elm/catalog” for the public version and an empty string for development.

In my route parser, I needed to define a different “top level” parser which would first consume the path prefix, and then parse the remaining path to determine whether to show packages or tools:

routeParser : String -> UrlParser.Parser (Route -> a) a
routeParser urlPrefix =
    let
        top =
            topLevelParser urlPrefix
    in
    Url.Parser.oneOf
        [ Url.Parser.map (PackageRoute "dev/algorithms") <| top </> Url.Parser.s "packages"
        , Url.Parser.map (ToolRoute "build") <| top </> Url.Parser.s "tools"
        -- default to packages if the tab isn't specified in the route:
        , Url.Parser.map (PackageRoute "dev/algorithms") <| top  
        ]

topLevelParser : String -> UrlParser.Parser a a
topLevelParser urlPrefix =
    case urlPrefix of
        "" ->
            Url.Parser.top

        s ->
            s
                |> String.split "/"
                |> List.map Url.Parser.s
                |> List.foldr (</>) Url.Parser.top

In the last branch of the case expression of topLevelParser, I construct a portion of the parser to consume each path segment in the prefix with Url.Parser.s. When the prefix is “elm/catalog”, the result is Url.Parser.top </> Url.Parser.s "elm" </> Url.Parser.s "catalog".

That’s it!

This post covers most of the URL parsing functionality in elm/url. Hopefully it’s enough to allow you to handle most URL parsing tasks you might encounter.

Would you like to dive further into Elm?
📢 My book
Practical Elm
skips the basics and gets straight into the nuts-and-bolts of building non-trivial apps.
🛠 Things like building out the UI, communicating with servers, parsing JSON, structuring the application as it grows, testing, and so on.
Practical Elm