purescript
type-level
parsing

Well-typed path params in PureScript 0.12

Previously, I wrote about using tuples to represent URLs with parameters in my article Type-level Path Params parsed to Records with PureScript. While the approach described in this article is fairly interesting, it's overall fairly disappointing to work with when the way to work with this looks like this:

type MyRoute = S "hello" / S "world" / Param "id" Int / Param "name" String

Record-Format

With the release of PureScript 0.12-rc, we can revisit the work that Csongor did in his post Well-typed printfs cannot go wrong, where the combination of two major features allows for type-level parsing of Symbols:

  • Symbol.Cons, which gives us the ability to constrain that a given sym :: Symbol can be split into head :: Symbol for the first "character" and tail :: Symbol for the remainder.
  • Instance chains, which gives us the ability to define overlapping instances that can be resolved in defined order.

The post itself has a lot of details on how all this works, but for our usees, we will only need to use Csongor's Record-Format library, which defines two very useful kinds and a Parse type class:

foreign import kind Fmt -- ^ a format token is...
foreign import data Var :: Symbol -> Fmt -- ^ either a variable (to be replaced)
foreign import data Lit :: Symbol -> Fmt -- ^ or a literal

-- | A list of format tokens
foreign import kind FList
foreign import data FNil :: FList
foreign import data FCons :: Fmt -> FList -> FList

class Parse (i :: Symbol) (o :: FList) | i -> o

This type class works in that you provide a Symbol in the form "hello {name}", and this will be parsed in the type level to produce a FList of FCons (Lit "hello ") (FCons (Var "name") FNil).

Change of Approach

So the original post I made, I had some key differences that forced me to work with things differerently:

  • Because I was working with Tuple termini, I had to get both the builder of the subrow and the remaining string of Tuple branches, as the right branch will be run independently of the left.
  • As there is no type information associated with the parameter in the Symbol parser, initially I will have to determine a homogeneous String record type and convert it later if I want to use different types. This doesn't give up much in the end though, as you'll see, as I do not have to provide contextual type information in this case.

Kushiyaki

So I made an updated approach which uses the Parse type class and the kinds defined in Record-Format to effectively run the opposite of the library: parse out from a String value using a Symbol template, with parameters being used to build up a homogeneous String record type. I then also implemented a ConvertRecord type class to then allow for parsing the fields here into desired other types using type annotations.

ParseURL

So parsing a URL will take a Symbol template and the String to be parsed, resulting in either an error (low-effort represented here as String) and the parsed record result:

class ParseURL (url :: Symbol) (row :: # Type) where
  parseURL :: SProxy url -> String -> Either String { | row }

This class has one instance, where the Symbol template will be converted to the FList defined in Record-Format, and this will be used to drive parsing of the string input to produce a Record Builder to build the record of matched parameters:

instance parseStringInst ::
  ( Parse url xs
  , ParseURLImpl xs () row
  ) => ParseURL url row where
  parseURL _ s
      = Builder.build <@> {}
    <$> parseURLImpl (FProxy :: FProxy xs) s

ParseURLImpl

From our intended usage, our ParseURLImpl type class can be defined like so:

class ParseURLImpl (xs :: FList) (from :: # Type) (to :: # Type)
  | xs -> from to where
  parseURLImpl
    :: FProxy xs
    -> String
    -> Either String (Builder { | from } { | to })

And for starters, our FNil instance can return a Builder of an empty record:

instance nilParseURLImpl :: ParseURLImpl FNil () () where
  parseURLImpl _ _ = pure identity

FCons Lit

The simpler FCons case to handle is the case of the Lit, the literal segment that we need to match for. For example, given some template "/hello/{name}", the FCons cell for the literal will be FCons (Lit "/hello/") tail, which we can use to strip the prefix from the input string and apply the remainder to the tail.

instance consLitParseURLImpl ::
  ( IsSymbol segment
  , ParseURLImpl tail from to
  ) => ParseURLImpl (FCons (Lit segment) tail) from to where
  parseURLImpl _ s =
    case stripPrefix (Pattern segment) s of
      Nothing ->
        Left $ "could not strip segment " <> segment <> " from path " <> s
      Just remaining ->
        parseURLImpl (FProxy :: FProxy tail) remaining
    where
      segment = reflectSymbol (SProxy :: SProxy segment)

So in our example, a String value "/hello/Bill" would be stripped of "/hello/" and the following call to parseURLImpl would use the remaining string "Bill".

FCons var

For the variable matching case, we will make a Builder that will insert the parsed string value into a the label inside the Var. Since we are working with URL strings, we delimit the string with "/" to split the string, or use the whole remaining string.

instance consVarParseURLImpl ::
  ( IsSymbol name
  , Row.Cons name String from' to
  , Row.Lacks name from'
  , ParseURLImpl tail from from'
  ) => ParseURLImpl (FCons (Var name) tail) from to where
  parseURLImpl _ s = do
    split' <- split
    let first = Builder.insert nameP split'.before
    rest <- parseURLImpl (FProxy :: FProxy tail) split'.after
    pure $ first <<< rest
    where
      nameP = SProxy :: SProxy name
      name = reflectSymbol nameP
      split = maybe (Left "error") Right $ case indexOf (Pattern "/") s of
        Just idx -> splitAt idx s
        Nothing -> pure { before: s, after: "" }

And this is about it, where we compose our current Builder from parsing the parameter with the Builder produced by parsing the rest of the string.

ConvertRecord

For the case when we want to have a heterogeneous record where some fields may be e.g. Int, we can choose to parse the record result further. The only problem here is that then you need to provide a type annotation for what you want the result to be typed, as the type of the fields can't be determined without that information. While I won't dig into the implementation (as it's mostly boring), you can check it out in the implementation if you'd like.

class ReadParam a where
  readParam :: String -> Either String a

class ConvertRecord (i :: # Type) (o :: # Type) where
  convertRecord :: { | i } -> Either String { | o}

Usage

First, we can see how the types are fully determined. We can define a binding with a type wildcard and have the compiler give us the type:

  let (parseURL'
        :: _ )
        = parseURL (SProxy :: SProxy "/hello/{name}/{age}")

This will then expand:

  let (parseURL'
        -- inferred type:
        :: String -> Either String { name :: String, age :: String })
        = parseURL (SProxy :: SProxy "/hello/{name}/{age}")

So we can see this in action as we throw a test case at it:

main :: Effect Unit
main = do
  let (parseURL'
        -- inferred type:
        :: String -> Either String { name :: String, age :: String })
        = parseURL (SProxy :: SProxy "/hello/{name}/{age}")
  let parsed = parseURL' "/hello/Bill/12"
  case parsed of
    Left e -> do
      log $ "didn't work: " <> e
      assert $ 1 == 2
    Right r -> do
      assert $ r.name == "Bill"
      assert $ r.age == "12"

And as we expected, the type of the r in the right branch is known to have fields name and age which are both String.

And in the case that we want to convert the record fields, all we need to do is provide a type annotation for r:

  let parseURL2' = parseURL (SProxy :: SProxy "/hello/{name}/{age}")
  let parsed2 = parseURL2' "/hello/Bill/12"
  case convertRecord =<< parsed of
    Left e -> do
      log $ "didn't work: " <> e
      assert $ 1 == 2
    Right (r :: { name :: String, age :: Int }) -> do
      assert $ r.name == "Bill"
      assert $ r.age == 12

Amazing.

Conclusion

Hopefully this has shown you that with 0.12 giving us the ability to effectively parse Symbol, we can create easy-to-use libraries that give us a lot of power with very little work. I think there are plenty of things people could do with template Symbols that we haven't found out yet that could make for even nicer solutions.

Links