joshburgess/elm-route-craft/1.0.0

Route

Bidirectional route definition combinators.

Define a route once, get both URL parsing and URL building from the same definition. No code generation required.

Approach

The core idea is a Codec that threads both a parser and a builder through a pipeline. Each segment combinator advances both sides simultaneously.

The codec function takes two arguments:

  1. The route constructor (for parsing: BlogPost : Int -> Route)
  2. An extractor (for building: \r -> case r of BlogPost id -> Just (id, ()) ; _ -> Nothing)

The extractor returns a tuple-chain that mirrors the constructor's arguments. The compiler checks that the constructor and extractor agree.

Example

type Route
    = Home
    | Blog
    | BlogPost Int
    | UserProfile String
    | UserRepo String String


router : Router Route
router =
    oneOf
        [ top Home
        , codec Blog (\r -> case r of Blog -> Just () ; _ -> Nothing)
            |> literal "blog"
        , codec BlogPost (\r -> case r of BlogPost id -> Just ( id, () ) ; _ -> Nothing)
            |> literal "blog"
            |> int
        , codec UserProfile (\r -> case r of UserProfile name -> Just ( name, () ) ; _ -> Nothing)
            |> literal "user"
            |> string
        , codec UserRepo (\r -> case r of UserRepo owner repo -> Just ( owner, ( repo, () ) ) ; _ -> Nothing)
            |> literal "user"
            |> string
            |> string
        ]


parse router "/blog/42"          --> Just (BlogPost 42)
toPath router (BlogPost 42)      --> Just "/blog/42"
toPath router (UserRepo "a" "b") --> Just "/user/a/b"

Types

type Codec parseState buildState route

A bidirectional route codec.

Type parameters:

  • parseState — what the parser still needs to produce (starts as the constructor type, e.g., Int -> Route, consumed down to Route)
  • buildState — what the builder still needs to consume (starts as the extractor result type, e.g., ( Int, () ), consumed down to ())
  • route — the final route type
type Router route

A router holds finalized codecs with uniform type and an optional base path that prefixes every URL.

Defining Routes

constructor
-> (route -> Maybe buildTuple)
-> Codec constructor buildTuple route

Begin a route codec definition.

The first argument is the route constructor used for parsing. The second argument extracts parameters from a route value for building.

The extractor should return Just (params) if the route matches, where params is a right-nested tuple chain ending in ():

  • 0 params: Just ()
  • 1 param: Just ( a, () )
  • 2 params: Just ( a, ( b, () ) )

Example:

codec BlogPost (\r -> case r of BlogPost id -> Just ( id, () ) ; _ -> Nothing)
    |> literal "blog"
    |> int
top : route -> Codec route () route

A route that matches the root path "/" with no parameters.

top Home  -- matches "/"

Segment Combinators

literal : String -> Codec parse build route -> Codec parse build route

Add a literal path segment that must match exactly. Does not consume any type parameter from the constructor or extractor.

codec Blog (\r -> case r of Blog -> Just () ; _ -> Nothing)
    |> literal "blog"
literalCi : String -> Codec parse build route -> Codec parse build route

Like literal, but matches case-insensitively on parse. Always builds the canonical (original-cased) form, so URLs the library generates remain consistent.

Useful when migrating from frameworks that match paths case- insensitively (Express, Phoenix), or when you want /Blog and /BLOG to resolve to the same route as /blog for SEO redirect hygiene without writing every variant by hand.

codec Blog (\r -> case r of Blog -> Just () ; _ -> Nothing)
    |> literalCi "blog"


-- parses "/blog", "/Blog", "/BLOG" all as Blog
-- builds Blog as "/blog"
int :
Codec (Int -> parse) ( Int, build ) route
-> Codec parse build route

Capture an Int path segment.

codec BlogPost (\r -> case r of BlogPost id -> Just ( id, () ) ; _ -> Nothing)
    |> literal "blog"
    |> int
Codec (String -> parse) ( String, build ) route
-> Codec parse build route

Capture a String path segment. Values are percent-encoded when building and percent-decoded when parsing, so a value containing /, spaces, or other URL-significant characters round-trips cleanly.

codec UserProfile (\r -> case r of UserProfile name -> Just ( name, () ) ; _ -> Nothing)
    |> literal "user"
    |> string
(String -> Maybe a)
-> (a -> String)
-> Codec (a -> parse) ( a, build ) route
-> Codec parse build route

Capture a path segment with a custom parser and builder.

custom Slug.fromString Slug.toString
    : Codec (Slug -> parse) ( Slug, build ) route
    -> Codec parse build route

Additional Segment Primitives

Codec (Float -> parse) ( Float, build ) route
-> Codec parse build route

Capture a Float path segment.

codec Plot (\r -> case r of Plot v -> Just ( v, () ) ; _ -> Nothing)
    |> literal "plot"
    |> float
Codec (String -> parse) ( String, build ) route
-> Codec parse build route

Capture a non-empty String path segment. Fails to build (returns Nothing from toPath) if the value is empty, which is safer than silently producing a malformed URL like /user//repo.

codec UserProfile (\r -> case r of UserProfile name -> Just ( name, () ) ; _ -> Nothing)
    |> literal "user"
    |> nonEmptyString
Codec (String -> parse) ( String, build ) route
-> Codec parse build route

Capture a UUID path segment in 8-4-4-4-12 hex format. The string is returned as-is (lowercased on the way out for consistency); use custom with your own UUID type if you have one.

codec ItemPage (\r -> case r of ItemPage id -> Just ( id, () ) ; _ -> Nothing)
    |> literal "items"
    |> uuid

Constrained Segment Primitives

Int
-> Int
-> Codec (Int -> parse) ( Int, build ) route
-> Codec parse build route

Capture an Int path segment that must lie within [min, max] inclusive. Out-of-range values fail to parse, and toPath returns Nothing for out-of-range route values, so a route like Page 999 will not silently produce a URL that cannot round-trip.

codec Page (\r -> case r of Page n -> Just ( n, () ) ; _ -> Nothing)
    |> literal "page"
    |> intRange 1 100
Codec (String -> parse) ( String, build ) route
-> Codec parse build route

Capture a kebab-case slug path segment.

A slug is a non-empty string of lowercase ASCII letters, digits, and hyphens, with no leading or trailing hyphen and no consecutive hyphens. Out-of-shape values fail to parse, and toPath returns Nothing for slug-shaped fields whose value would not parse back.

codec Article (\r -> case r of Article s -> Just ( s, () ) ; _ -> Nothing)
    |> literal "articles"
    |> slug
-- matches "/articles/getting-started", parses to Article "getting-started"
-- "/articles/Bad_Slug" -> Nothing
List ( String, a )
-> Codec (a -> parse) ( a, build ) route
-> Codec parse build route

Capture a path segment that must be one of a finite set of strings, mapped to values of a custom type.

Mirrors Query.enum on the path side. Variants are tried in list order on both parse and build. Building a route value not present in the variant list returns Nothing so a malformed URL is never produced.

The variant value type must support structural equality (no functions inside the type).

type SortOrder
    = Asc
    | Desc


sortVariants : List ( String, SortOrder )
sortVariants =
    [ ( "asc", Asc ), ( "desc", Desc ) ]


codec Sort (\r -> case r of Sort o -> Just ( o, () ) ; _ -> Nothing)
    |> literal "sort"
    |> enum sortVariants
-- matches "/sort/asc"  -> Sort Asc
-- matches "/sort/desc" -> Sort Desc
-- "/sort/sideways"     -> Nothing

Splat Routes

Codec (List String -> parse) ( List String, build ) route
-> Codec parse build route

Capture all remaining path segments as a List String. This must be the final segment in a route definition, since it consumes everything that follows.

type Route
    = Docs (List String)


router =
    oneOf
        [ codec Docs (\r -> case r of Docs path -> Just ( path, () ))
            |> literal "docs"
            |> splat
        ]


parse router "/docs/intro/setup"  --> Just (Docs ["intro", "setup"])
parse router "/docs"              --> Just (Docs [])
toPath router (Docs ["a","b","c"]) --> Just "/docs/a/b/c"

Nesting

String
-> List (Codec route () route)
-> List (Codec route () route)

Group routes under a common path prefix.

router =
    oneOf
        (List.concat
            [ [ top Home ]
            , nest "blog"
                [ codec BlogList (\r -> case r of BlogList -> Just () ; _ -> Nothing)
                , codec BlogPost (\r -> case r of BlogPost id -> Just ( id, () ) ; _ -> Nothing)
                    |> int
                ]
            , nest "user"
                [ codec UserProfile (\r -> case r of UserProfile n -> Just ( n, () ) ; _ -> Nothing)
                    |> string
                ]
            ]
        )


-- Nesting can be nested:
nest "api" (nest "v1" [ ... ])

Query Parameters

QueryCodec query
-> Codec route () route
-> ( Codec route () route, QueryCodec query )

Attach a query parameter codec to a finalized route codec.

This is used with parseFull and toFullPath for routes that include query parameters.

Router

oneOf : List (Codec route () route) -> Router route

Combine finalized route codecs into a router. Routes are tried in definition order for both parsing and building.

router =
    oneOf
        [ top Home
        , codec Blog (\r -> case r of Blog -> Just () ; _ -> Nothing)
            |> literal "blog"
        , codec BlogPost (\r -> case r of BlogPost id -> Just ( id, () ) ; _ -> Nothing)
            |> literal "blog"
            |> int
        ]

Note: every codec must be fully applied (all parameters consumed) before being passed to oneOf. The compiler enforces this — a codec with unconsumed parameters will have the wrong type.

Base Path

withBasePath : String -> Router route -> Router route

Mount a router at a path prefix. All parse and toPath operations go through this prefix; routes that don't match the prefix fail to parse.

router =
    oneOf [ top Home, ... ]
        |> withBasePath "/admin"


parse router "/admin"          --> Just Home
parse router "/admin/blog"     --> Just Blog
parse router "/blog"           --> Nothing
toPath router Home             --> Just "/admin"
toPath router Blog             --> Just "/admin/blog"

The base path is normalized: leading slash is added if missing, trailing slash is stripped. Calling withBasePath again replaces the previous value (it does not stack).

Hash-routing functions (parseHash, toHashPath, fromUrlHash) ignore the base path on purpose — the fragment is independent of the server-served URL path.

Parsing and Building

parse : Router route -> String -> Maybe route

Parse a URL path string into a route value.

parse router "/blog/42" --> Just (BlogPost 42)
parse router "/unknown" --> Nothing
matches : Router route -> String -> Bool

Boolean check for whether a URL path matches any route in a router. A thin wrapper over parse for cases where you only need to know "does this path match" without the route value itself.

matches router "/blog/42"  --> True
matches router "/unknown"  --> False
toPath : Router route -> route -> Maybe String

Build a URL path string from a route value.

toPath router (BlogPost 42) --> Just "/blog/42"
toPath router Home          --> Just "/"
toSegments : Router route -> route -> Maybe (List String)

Build the path segments of a route as a list, without the leading slash and without joining. Each segment is percent-decoded, so the values match what was originally captured. Useful for breadcrumbs and custom rendering that needs each segment in isolation.

toSegments router (BlogPost 42) --> Just [ "blog", "42" ]
toSegments router Home          --> Just []

A router mounted at a base path includes the base segments first:

toSegments (router |> withBasePath "/admin") (BlogPost 42)
--> Just [ "admin", "blog", "42" ]

For the joined string form, use toPath.

fromUrl : Router route -> Url -> Maybe route

Parse a Url (from elm/url) into a route value.

parseFull : Router route -> QueryCodec query -> String -> Maybe ( route, query )

Parse a full URL path (with optional query string) into a route and query parameters.

parseFull router searchQuery "/search?q=elm&page=2"
--> Just ( SearchResults, { query = Just "elm", page = Just 2 } )
toFullPath : Router route -> QueryCodec query -> route -> query -> Maybe String

Build a full URL path with query string from a route and query parameters.

toFullPath router searchQuery SearchResults
    { query = Just "elm", page = Just 2 }
--> Just "/search?q=elm&page=2"

Canonicalization

Useful when paired with literalCi, intRange, slug, enum, or nested routes whose canonical form differs from accepted input.

canonicalize : Router route -> String -> Maybe String

Parse a path then rebuild it as the canonical form. Returns Nothing if the path does not parse.

Pairs with literalCi, intRange, slug, and enum, all of which accept inputs whose canonical form differs from what the user typed. Also collapses consecutive slashes and trailing slashes since the parser already tolerates them.

canonicalize router "/Blog"        --> Just "/blog"      -- with literalCi
canonicalize router "/blog/42/"    --> Just "/blog/42"
canonicalize router "//blog//42//" --> Just "/blog/42"
canonicalize router "/unknown"     --> Nothing

Operates on the path portion only. If your input includes a query string or fragment, split them off first and reattach with appendQuery / replaceQuery.

redirectTo : Router route -> String -> Maybe String

Like canonicalize, but returns Just only when the canonical form differs from the input. Returns Nothing when the input is already canonical or fails to parse, which collapses both "no redirect needed" and "404" into one branch the caller can handle as "continue with normal routing."

redirectTo router "/Blog"     --> Just "/blog"
redirectTo router "/blog"     --> Nothing
redirectTo router "/unknown"  --> Nothing

Useful in Browser.application's onUrlChange to emit a Browser.Navigation.replaceUrl when the URL is non-canonical:

case Route.redirectTo router url.path of
    Just canonical ->
        ( model, Nav.replaceUrl key canonical )


    Nothing ->
        -- normal routing
        ...

Hash Routing

For SPAs hosted where you can't control server rewrites (GitHub Pages, plain static hosts), put the route in the URL fragment. These functions read/write the fragment and ignore any router base path.

parseHash : Router route -> String -> Maybe route

Parse a fragment string into a route. Accepts the fragment with or without a leading #. An empty fragment is treated as /.

parseHash router "#/blog/42"  --> Just (BlogPost 42)
parseHash router "/blog/42"   --> Just (BlogPost 42)
parseHash router ""           --> Just Home

This bypasses any base path set on the router.

toHashPath : Router route -> route -> Maybe String

Build a route as a fragment with a leading #.

toHashPath router (BlogPost 42) --> Just "#/blog/42"
toHashPath router Home          --> Just "#/"

This bypasses any base path set on the router.

fromUrlHash : Router route -> Url -> Maybe route

Parse a Url's fragment as a route.

Useful in Browser.application's onUrlChange when using hash-style routing. An absent fragment is treated as the root.

Query String Utilities

Combinators for splicing raw query strings onto built paths. Useful when the query portion is computed imperatively (overlay tracking params, mutate one key, etc.) rather than declared in the codec.

appendQuery : String -> String -> String

Append a raw query string to a path. An empty query is dropped, so no trailing ? is produced. A leading ? on the query is tolerated.

appendQuery "page=2" "/search"  --> "/search?page=2"
appendQuery ""        "/search" --> "/search"
appendQuery "?page=2" "/search" --> "/search?page=2"

Useful in combination with toPath and Route.Query.fromList / Route.Query.merge for building URLs whose query portion is computed imperatively (overlay tracking params, mutate one key, etc.).

toPath router (Search "elm")
    |> Maybe.map (Route.appendQuery "utm_source=twitter")
replaceQuery : String -> String -> String

Replace the query portion of a path. Strips any existing query first, then appends the new one. An empty query removes any existing query without leaving a trailing ?.

replaceQuery "page=3" "/search?page=2"     --> "/search?page=3"
replaceQuery ""       "/search?page=2"     --> "/search"
replaceQuery "page=3" "/search"            --> "/search?page=3"
startsWith : Router route -> route -> route -> Bool

Test whether one route's path is a prefix of another's. Useful for highlighting nav links when the current route is under a section.

startsWith router Blog (BlogPost 42)         --> True
startsWith router (BlogPost 42) Blog         --> False
startsWith router Home anyOtherRoute         --> True
startsWith router Blog Blog                  --> True

Returns False if either route fails to build.