Skip to content

Two-way codecs

UrlCodec describes a route once and runs the description in both directions. The pipeline that parses a URL into a value also builds the URL back from it, so the links in your views and the parser in your router are the same code. Parse a URL, build it straight back, and you get the same string - the property the library's tests assert.

open Fable.UrlParser.UrlCodec

type Route =
    | Home
    | User of id: int

module Prisms =
    let user: Prism<Route, int> =
        {
            Embed = User
            Project =
                function
                | User id -> Some id
                | _ -> None
        }

let codecs =
    [
        Tuple.root |> Tuple.ofCase Home
        Tuple.path "user" |> Tuple.int |> Tuple.ofCase1 Prisms.user
    ]

RouteCodec.tryParsePath codecs "/user/7" |> printfn "%A" // Ok (User 7)
RouteCodec.tryToPath codecs (User 7) |> printfn "%A" // Some "user/7"
RouteCodec.tryToHash codecs (User 7) |> printfn "%A" // Some "#/user/7"

tryToPath builds without a leading slash; tryToHash prefixes #/.

Writing a codec

Two dialects, both finishing as a RouteCodec so they live in the same list:

  • Tuple is positional and terser. Use it for a route of one or two values.
  • Values names every value. Use it when the route carries a record, or more values than position can keep honest.

Either way the shape is the same: start at a path, add one step per value in URL order, then close with the union case.

Tuple

  1. Start the path

    Tuple.root matches no segment; Tuple.path "user" matches a literal one.

  2. Add one step per value

    In the order they appear in the URL.

    Tuple.string, Tuple.int and Tuple.custom each take a path segment. Tuple.fragment takes the # fragment. Tuple.Query.* mirrors every query combinator.

    Tuple.path "user" |> Tuple.int
    
  3. Close it with the union case

    Tuple.ofCase Home for a case with no fields - it needs no prism, since recognising it is equality. Tuple.ofCase1 to Tuple.ofCase3 for one to three values, each taking a prism.

    Tuple.path "user" |> Tuple.int |> Tuple.ofCase1 Prisms.user
    

Past ofCase3, reshape the accumulated tuple with Tuple.map and finish it as a single value:

open Fable.UrlParser.UrlCodec

type Route = Search of query: string * tags: string list * page: int option * draft: bool

module Prisms =
    let search: Prism<Route, string * string list * int option * bool> =
        {
            Embed = Search
            Project =
                function
                | Search(q, t, p, d) -> Some(q, t, p, d)
        }

let codecs =
    [
        Tuple.path "search"
        |> Tuple.Query.Required.string "q"
        |> Tuple.Query.strings "tag"
        |> Tuple.Query.Optional.int "page"
        |> Tuple.Query.flag "draft"
        |> Tuple.map
            (fun ((((_, q), t), p), d) -> (), (q, t, p, d))
            (fun ((), (q, t, p, d)) -> (((((), q), t), p), d))
        |> Tuple.ofCase1 Prisms.search
    ]

RouteCodec.tryParsePath codecs "/search?q=signals&tag=reactive&tag=fsharp" |> printfn "%A"
// Ok (Search ("signals", ["reactive"; "fsharp"], None, false))

At this arity, position is easy to get wrong. Values names every value instead.

Values

Values starts from a curried constructor and gives every step a getter. Swapping two values of the same type is a compile error here, and a silent bug in Tuple.

  1. Start from the constructor

    Values.create takes a function building the payload from its values, one argument per step to come.

  2. Add one step per value

    Each with the getter that reads it back.

    Values.segment matches a literal segment. Values.string, Values.int and Values.Query.* each take a value and a getter, as in Values.int _.Width.

  3. Close it

    Values.asCase with the case's prism.

open Fable.UrlParser.UrlCodec

type Filters =
    {
        Query: string
        Page: int option
        Sort: string
    }

type Route =
    | Browse of Filters
    | Tag of name: string

module Prisms =
    let browse: Prism<Route, Filters> =
        {
            Embed = Browse
            Project =
                function
                | Browse f -> Some f
                | _ -> None
        }

    let tag: Prism<Route, string> =
        {
            Embed = Tag
            Project =
                function
                | Tag n -> Some n
                | _ -> None
        }

let browseCodec =
    Values.create (fun query page sort ->
        {
            Query = query
            Page = page
            Sort = sort
        }
    )
    |> Values.segment "browse"
    |> Values.Query.Required.string "q" _.Query
    |> Values.Query.Optional.int "page" _.Page
    |> Values.Query.Required.string "sort" _.Sort
    |> Values.asCase Prisms.browse

let tagCodec = Tuple.path "tag" |> Tuple.string |> Tuple.ofCase1 Prisms.tag

let codecs =
    [
        browseCodec
        tagCodec
    ]

RouteCodec.tryParsePath codecs "/browse?q=signals&sort=recent" |> printfn "%A"
// Ok (Browse { Query = "signals"; Page = None; Sort = "recent" })

Browse
    {
        Query = "signals"
        Page = Some 2
        Sort = "name"
    }
|> RouteCodec.tryToPath codecs
|> printfn "%A" // Some "browse?q=signals&page=2&sort=name"

The parse side applies the constructor argument by argument, in pipeline order. The build side reads each value through its getter (_.Query), which is how it knows where in the record that value lives.

Anonymous records

Getter labels on an anonymous record cannot be resolved from the constructor alone. Values.createFor takes the case's prism as an inference witness; pass the same prism again to Values.asCase at the end:

open Fable.UrlParser.UrlCodec

type Route =
    | Home
    | Display of {| Width: int; Height: int |}

module Prisms =
    let display: Prism<Route, {| Width: int; Height: int |}> =
        {
            Embed = Display
            Project =
                function
                | Display v -> Some v
                | _ -> None
        }

let displayCodec =
    Values.createFor Prisms.display (fun width height ->
        {|
            Width = width
            Height = height
        |}
    )
    |> Values.segment "display"
    |> Values.int _.Width
    |> Values.int _.Height
    |> Values.asCase Prisms.display

let codecs = [ displayCodec ]

RouteCodec.tryParsePath codecs "/display/1920/1080" |> printfn "%A"
// Ok (Display {| Height = 1080; Width = 1920 |})

RouteCodec.tryToPath codecs (Display {| Width = 800; Height = 600 |}) |> printfn "%A"
// Some "display/800/600"

Reshaping

Values.map converts a finished values codec through a total, two-way conversion - for example lifting the values into a wrapper type just before Values.asCase:

codec |> Values.map UserId (fun (UserId id) -> id)

Prisms

Prism<'Route, 'Values> is a record of two functions, one per direction:

type Prism<'Route, 'Values> =
    {
        Embed: 'Values -> 'Route
        Project: 'Route -> 'Values option
    }

'Route is your route union. 'Values is what the case carries: one value for Tuple.ofCase1, a pair for Tuple.ofCase2, a triple for Tuple.ofCase3.

Embed builds a route from the values, so it is usually the case name on its own - Embed = User.

Project goes the other way, returning None when the route is a different case. That None is how building a URL fails over to the next codec in the list. A union with other cases therefore needs a | _ -> None branch; a single-case union is already covered by its one pattern.

The library does not derive prisms for you, so a union with five cases that carry values needs five of them. Keeping them in a Prisms module beside the route type is the convention the API's own documentation uses.

Omitted values

A false flag, an empty list and a None option are left out of the built URL rather than becoming draft=false or page=. A URL is compared as a string by caches, analytics and the back button, so the absent state gets exactly one spelling:

open Fable.UrlParser.UrlCodec

type Route = Search of query: string * page: int option * draft: bool

module Prisms =
    let search: Prism<Route, string * int option * bool> =
        {
            Embed = Search
            Project =
                function
                | Search(q, p, d) -> Some(q, p, d)
        }

let codecs =
    [
        Tuple.path "search"
        |> Tuple.Query.Required.string "q"
        |> Tuple.Query.Optional.int "page"
        |> Tuple.Query.flag "draft"
        |> Tuple.ofCase3 Prisms.search
    ]

RouteCodec.tryToPath codecs (Search("signals", Some 3, true)) |> printfn "%A"
// Some "search?q=signals&page=3&draft"

RouteCodec.tryToPath codecs (Search("signals", None, false)) |> printfn "%A"
// Some "search?q=signals" - the empty states vanished
Edit this page