joshburgess/elm-route-craft/1.0.0

Route.Query

Typed, bidirectional query parameter handling.

Query parameters are defined as codecs that can both parse query strings and build them back. They compose independently from path codecs.

type alias SearchParams =
    { query : Maybe String
    , page : Int
    , tags : List String
    }


searchQuery : QueryCodec SearchParams
searchQuery =
    Query.succeed SearchParams
        |> Query.with .query (Query.string "q")
        |> Query.with .page (Query.int "page" |> Query.withDefault 1)
        |> Query.with .tags (Query.list "tag")
        |> Query.build


Query.parseQuery searchQuery "q=elm&page=2&tag=fp&tag=web"
--> { query = Just "elm", page = 2, tags = [ "fp", "web" ] }


Query.buildQuery searchQuery
    { query = Just "elm", page = 2, tags = [ "fp", "web" ] }
--> "q=elm&page=2&tag=fp&tag=web"

Types

type QueryCodec a

A bidirectional codec for a single query parameter or a composed set of query parameters.

The internal requiredKeys field lists keys flagged with Query.required. It is consulted only by parseQueryStrict; lenient parseQuery ignores it.

Primitive Codecs

All primitives that may be absent return Maybe a. Lift to a non-Maybe type with a default via withDefault.

string : String -> QueryCodec (Maybe String)

An optional string query parameter.

Query.string "q"
-- parses "?q=hello" -> Just "hello"
-- parses "?q="      -> Nothing  (empty values are treated as absent)
-- parses "?"        -> Nothing


-- builds Just "hello" -> "q=hello"
-- builds Nothing      -> ""
-- builds (Just "")    -> ""

For the older "missing means empty string" behavior, use:

Query.string "q" |> Query.withDefault ""
int : String -> QueryCodec (Maybe Int)

An optional Int query parameter.

Query.int "page"
-- parses "?page=3"   -> Just 3
-- parses "?page=abc" -> Nothing
-- parses "?"         -> Nothing
float : String -> QueryCodec (Maybe Float)

An optional Float query parameter. Mirrors Route.float on the path side and pairs with Query.int for non-integer numeric values.

Query.float "ratio"
-- parses "?ratio=0.5"  -> Just 0.5
-- parses "?ratio=abc"  -> Nothing
-- parses "?"           -> Nothing
bool : String -> QueryCodec (Maybe Bool)

An optional boolean query parameter. Parses "true"/"1" as True, "false"/"0" as False.

Query.bool "active"
-- parses "?active=true" -> Just True
-- parses "?active=0"    -> Just False
-- parses "?"            -> Nothing
flag : String -> QueryCodec Bool

A flag query parameter. Present = True, absent = False. The value is ignored; only presence matters.

Query.flag "verbose"
-- parses "?verbose"           -> True
-- parses "?verbose=anything"  -> True
-- parses "?"                  -> False

Collection and Enum Codecs

enum : String -> List ( String, a ) -> QueryCodec (Maybe a)

An enum query parameter that maps between strings and custom types.

type SortOrder
    = DateAsc
    | DateDesc
    | Title


sortCodec : QueryCodec (Maybe SortOrder)
sortCodec =
    Query.enum "sort"
        [ ( "date-asc", DateAsc )
        , ( "date-desc", DateDesc )
        , ( "title", Title )
        ]
list : String -> QueryCodec (List String)

A list-valued query parameter. Repeated keys are collected into a list.

Query.list "tag"
-- parses "?tag=elm&tag=fp" -> [ "elm", "fp" ]
-- parses "?"               -> []
csv : String -> QueryCodec (List String)

A comma-separated list-valued query parameter. Parses ?tag=fp,web,clojure into [ "fp", "web", "clojure" ].

Query.csv "tag"
-- parses "?tag=fp,web" -> [ "fp", "web" ]
-- parses "?tag="       -> []
-- parses "?"           -> []

Distinct from Query.list, which collects repeated keys (?tag=fp&tag=web). Both styles are common in the wild; choose the one your URLs already use.

The values must not contain commas. If they might, use Query.list instead, since this codec uses , as the delimiter and would treat an embedded comma as a separator. Empty values produced by adjacent or trailing commas are filtered out, so Query.csv and Query.list agree on whether a key contributes any values.

Modifiers

withDefault : a -> QueryCodec (Maybe a) -> QueryCodec a

Provide a default value for a Maybe-shaped codec, lifting it to a non-Maybe type. The default is omitted from the built query string, so round-tripping still works.

Query.int "page" |> Query.withDefault 1
-- parses "?"        -> 1
-- parses "?page=2"  -> 2
-- builds 1          -> ""
-- builds 2          -> "page=2"

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

required : String -> QueryCodec a -> QueryCodec a

Mark a query parameter as required. The key is added to the codec's required-key list, which is consulted only by parseQueryStrict. Lenient parseQuery ignores the marker, so this modifier never changes a value's parsed type.

Query.string "q" |> Query.required "q"
-- still QueryCodec (Maybe String)
-- but parseQueryStrict fails when "q" is absent

The key is repeated explicitly so that required composes with any codec, including ones built from map or withDefault whose own key is not externally inspectable. If the key is already marked required, this is a no-op.

Pipeline

Build composite query codecs in a record-friendly pipeline. This is the recommended way to compose more than one or two parameters.

type Pipeline state result

An intermediate builder for a pipelined query codec. Construct one with succeed, add fields with with, finalize with build.

The two type parameters are:

  • state — the parser state, which evolves from the constructor type (e.g., String -> Int -> SearchParams) to the final result type as fields are added.
  • result — the final record type. Stays constant across the pipeline so each with step can extract the field value from a record value.
succeed : constructor -> Pipeline constructor result

Begin a pipelined query codec from a record constructor.

Query.succeed SearchParams
    |> Query.with .q (Query.string "q")
    |> Query.with .page (Query.int "page" |> Query.withDefault 1)
    |> Query.build
(result -> a)
-> Pipeline (a -> next) result
-> Pipeline next result

Add a field to a pipelined query codec. The first argument is a record-field accessor used when building. The second is the codec for that field.

Query.succeed SearchParams
    |> Query.with .q (Query.string "q")
build : Pipeline result result -> QueryCodec result

Finalize a pipeline into a QueryCodec.

The state and result types must be equal — i.e., every field has been provided to satisfy the constructor passed to succeed.

Tuple Combinators

The original mapN combinators. They remain exported for backwards compatibility, but new code should prefer the pipeline.

map : (a -> b) -> (b -> a) -> QueryCodec a -> QueryCodec b

Map over a query codec's parsed value.

(a -> b -> result)
-> (result -> ( a, b ))
-> QueryCodec result

Combine two query codecs using a constructor and a decomposer.

For three or more fields prefer the succeed/with/build pipeline.

map2 SearchQuery
    (\s -> ( s.query, s.page ))
    (Query.string "q")
    (Query.int "page")
(a -> b -> c -> result)
-> (result -> ( a, b, c ))
-> QueryCodec result

Combine three query codecs.

For three or more fields prefer the succeed/with/build pipeline.

(a -> b -> c -> d -> result)
-> (result -> ( ( a, b ), ( c, d ) ))
-> QueryCodec result

Combine four query codecs.

For three or more fields prefer the succeed/with/build pipeline.

Parsing and Building

parseQuery : QueryCodec a -> String -> a

Parse a query string into a value using a codec. The query string should not include the leading "?".

parseQuery codec "page=3&sort=date"
parseQueryStrict : QueryCodec a -> String -> Maybe a

Strict variant of parseQuery. Returns Nothing if any key flagged with Query.required is absent from the input. Otherwise returns Just of the same value parseQuery would have produced.

paramsCodec =
    Query.succeed identity
        |> Query.with identity (Query.string "q" |> Query.required "q")
        |> Query.build


Query.parseQueryStrict paramsCodec "q=elm"   --> Just (Just "elm")
Query.parseQueryStrict paramsCodec ""        --> Nothing

Useful when a route's query parameter is not actually optional and a missing value should fail the route match rather than silently falling back to a default.

parseQueryResult : QueryCodec a -> String -> Result (List String) a

Result-typed variant of parseQueryStrict. Returns Err missingKeys listing every required key absent from the input, or Ok of the parsed value when all required keys are present.

paramsCodec =
    Query.succeed Pair
        |> Query.with .q (Query.string "q" |> Query.required "q")
        |> Query.with .page (Query.int "page" |> Query.required "page")
        |> Query.build


Query.parseQueryResult paramsCodec "q=elm&page=2"
--> Ok (Pair (Just "elm") (Just 2))


Query.parseQueryResult paramsCodec "q=elm"
--> Err [ "page" ]


Query.parseQueryResult paramsCodec ""
--> Err [ "q", "page" ]

Useful when missing keys should produce a meaningful error message (form validation feedback, structured logs, an HTTP 400 response).

buildQuery : QueryCodec a -> a -> String

Build a query string from a value using a codec. Returns the query string without a leading "?". Returns empty string if no parameters are produced.

buildQuery codec { page = Just 3, sort = Just DateAsc }
--> "page=3&sort=date-asc"

Url Parsing

Convenience wrappers over the parseQuery* family that take a Url directly and read its query field, removing url.query |> Maybe.withDefault "" boilerplate at every call site.

parseUrl : QueryCodec a -> Url -> a

Convenience wrapper over parseQuery that takes a Url directly and reads its query field. Saves the url.query |> Maybe.withDefault "" |> Query.parseQuery codec boilerplate at every call site, common in Browser.application page init functions.

Query.parseUrl searchQuery url
--> { query = Just "elm", page = 1, tags = [] }

An absent query (url.query == Nothing) is treated as empty.

parseUrlStrict : QueryCodec a -> Url -> Maybe a

Strict variant of parseUrl. Returns Nothing if any key flagged with Query.required is absent from the URL's query. Otherwise returns Just of the same value parseUrl would have produced.

Query.parseUrlStrict requiredQuery url
--> Just { q = "elm" }   -- when q is present
--> Nothing              -- when q is absent
parseUrlResult : QueryCodec a -> Url -> Result (List String) a

Result-typed variant of parseUrl. Returns Err missingKeys listing every required key absent from the URL's query, or Ok of the parsed value when all required keys are present.

Query.parseUrlResult requiredQuery url
--> Ok { q = "elm", page = 2 }   -- both present
--> Err [ "page" ]               -- only q present
--> Err [ "q", "page" ]          -- both missing

Raw Query Manipulation

For the common "change one key, keep all the others" pattern in pagination and filter UIs, work directly on the raw query string.

update : String -> Maybe String -> String -> String

Set or remove a single key in a raw query string, leaving every other key untouched.

Pass Just value to set the key (replacing all existing occurrences and appending if absent), or Nothing to remove it (equivalent to drop). A leading ? on the input is accepted and stripped. The result is returned without a leading ?.

Query.update "page" (Just "5") "q=elm&page=2&tag=fp"
--> "q=elm&tag=fp&page=5"


Query.update "page" Nothing "q=elm&page=2"
--> "q=elm"


Query.update "sort" (Just "date") ""
--> "sort=date"

This is intended for "navigate to the same page with page= changed" patterns where rebuilding the full typed record would be needlessly verbose.

drop : String -> String -> String

Remove every occurrence of a key from a raw query string. Equivalent to update key Nothing.

Query.drop "tag" "q=elm&tag=fp&tag=web"
--> "q=elm"

Raw Query Conversion

Escape hatches that expose the parsed List ( String, String ) representation, plus targeted lookup and merging. Use these when you need a query operation that does not fit the typed pipeline.

toList : String -> List ( String, String )

Parse a raw query string into a list of key-value pairs.

Tolerates a leading ?. Preserves the original order and any duplicate keys. Keys and values are percent-decoded, so the round trip with Query.fromList is clean even for keys or values that contain URL-significant characters.

Query.toList "?q=elm&tag=fp&tag=web"
--> [ ( "q", "elm" ), ( "tag", "fp" ), ( "tag", "web" ) ]


Query.toList ""
--> []
fromList : List ( String, String ) -> String

Build a raw query string from a list of key-value pairs.

Keys and values are percent-encoded. The order of pairs is preserved. A pair with an empty value is rendered as a bare key (flag rather than flag=), matching the behavior of Query.flag.

Query.fromList [ ( "q", "elm" ), ( "tag", "fp" ), ( "tag", "web" ) ]
--> "q=elm&tag=fp&tag=web"


Query.fromList []
--> ""
lookup : String -> String -> Maybe String

Look up the first value for a key in a raw query string.

Tolerates a leading ?. Returns Nothing if the key is absent. For multi-valued keys, only the first value is returned, matching how the typed primitives like Query.string behave.

Query.lookup "q" "q=elm&page=2"
--> Just "elm"


Query.lookup "missing" "q=elm"
--> Nothing


Query.lookup "tag" "tag=fp&tag=web"
--> Just "fp"
merge : String -> String -> String

Combine two raw query strings.

On key conflict every entry from the first argument is kept and every entry from the second argument with that key is dropped. Keys present only in the second argument are appended in their original order. Tolerates a leading ? on either side.

Query.merge "q=elm" "utm_source=twitter"
--> "q=elm&utm_source=twitter"


Query.merge "q=elm" "q=haskell&utm_source=twitter"
--> "q=elm&utm_source=twitter"


Query.merge "" "utm_source=twitter"
--> "utm_source=twitter"

Useful for the "overlay tracking parameters on a route's natural query" pattern, where the route owns its own keys but other layers (analytics, A/B testing, deep links) need to add their own without clobbering them.

preserve : List String -> String -> String

Keep only the listed keys from a raw query string, dropping everything else. Order of the surviving entries is preserved. Tolerates a leading ?.

Query.preserve [ "q", "page" ] "q=elm&page=2&utm_source=tw"
--> "q=elm&page=2"


Query.preserve [ "utm_source", "utm_campaign" ]
    "q=elm&utm_source=tw&utm_campaign=launch"
--> "utm_source=tw&utm_campaign=launch"


Query.preserve [] "q=elm&page=2"
--> ""

Useful for the "carry these tracking parameters across navigation" pattern, or for stripping a query string down to a known-safe allowlist before logging or caching.

except : List String -> String -> String

Drop the listed keys from a raw query string, keeping everything else. The list-based complement to Query.drop. Tolerates a leading ?.

Query.except [ "utm_source", "utm_campaign" ]
    "q=elm&page=2&utm_source=tw&utm_campaign=launch"
--> "q=elm&page=2"


Query.except [ "page" ] "q=elm&page=2"
--> "q=elm"


Query.except [] "q=elm&page=2"
--> "q=elm&page=2"

Useful when you have a denylist of keys to strip (sensitive params before sharing a URL, navigation-only keys before persisting state) without having to list out every key you do want to keep.

canonicalString : String -> String

Normalize a raw query string to a canonical form by sorting its entries lexicographically (by key, then by value). Percent-encoding is also normalized as a side effect of round-tripping through toList / fromList.

Useful as a stable cache key, deduplication discriminator, or analytics input where two semantically-equivalent URLs should hash to the same string.

Query.canonicalString "page=2&q=elm"
--> "page=2&q=elm"


Query.canonicalString "q=elm&page=2"
--> "page=2&q=elm"


Query.canonicalString "tag=web&q=elm&tag=fp"
--> "q=elm&tag=fp&tag=web"

Tolerates a leading ?; the result never includes one.