Bidirectional route definition combinators.
Define a route once, get both URL parsing and URL building from the same definition. No code generation required.
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:
BlogPost : Int -> Route)\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.
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"A router holds finalized codecs with uniform type and an optional base path that prefixes every URL.
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 ():
Just ()Just ( a, () )Just ( a, ( b, () ) )Example:
codec BlogPost (\r -> case r of BlogPost id -> Just ( id, () ) ; _ -> Nothing)
|> literal "blog"
|> intA route that matches the root path "/" with no parameters.
top Home -- matches "/"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"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"Capture an Int path segment.
codec BlogPost (\r -> case r of BlogPost id -> Just ( id, () ) ; _ -> Nothing)
|> literal "blog"
|> intCapture 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"
|> stringCapture a path segment with a custom parser and builder.
custom Slug.fromString Slug.toString
: Codec (Slug -> parse) ( Slug, build ) route
-> Codec parse build routeCapture a Float path segment.
codec Plot (\r -> case r of Plot v -> Just ( v, () ) ; _ -> Nothing)
|> literal "plot"
|> floatCapture 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"
|> nonEmptyStringCapture 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"
|> uuidCapture 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 100Capture 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" -> NothingCapture 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" -> NothingCapture 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"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" [ ... ])Attach a query parameter codec to a finalized route codec.
This is used with parseFull and toFullPath for routes that include
query parameters.
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.
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.
Parse a URL path string into a route value.
parse router "/blog/42" --> Just (BlogPost 42)
parse router "/unknown" --> NothingBoolean 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" --> FalseBuild a URL path string from a route value.
toPath router (BlogPost 42) --> Just "/blog/42"
toPath router Home --> Just "/"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.
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 } )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"Useful when paired with literalCi, intRange, slug, enum, or
nested routes whose canonical form differs from accepted input.
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" --> NothingOperates on the path portion only. If your input includes a query
string or fragment, split them off first and reattach with
appendQuery / replaceQuery.
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" --> NothingUseful 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
...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.
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 HomeThis bypasses any base path set on the router.
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.
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.
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.
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")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"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 --> TrueReturns False if either route fails to build.
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 toRoute)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