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 givensym :: Symbol
can be split intohead :: Symbol
for the first "character" andtail :: 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 homogeneousString
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
- This library https://github.com/justinwoo/purescript-kushiyaki
- Well-typed printfs cannot go wrong http://kcsongor.github.io/purescript-safe-printf/
- PureScript 0.12-rc1 release notes https://github.com/purescript/purescript/releases/tag/v0.12.0-rc1