Well made Tortellini with PureScript

Well made Tortellini with PureScript

Talk on PureScript-Tortellini for NY PureScript, 24 Apr 2018

3b48c91bf6b6f0bfd0fda50625598656?s=128

Justin Woo

April 24, 2018
Tweet

Transcript

  1. Well made Tortellini with PureScript 24 Apr @NY PureScript by

    JW
  2. Who am I • Korean-American living in Helsinki ◦ I

    didn’t know what Moomin was before I moved there • Used to live in DC (Clarendon) ◦ This is where you’re supposed to boo • PureScript user since 2016 ◦ Now writing PureScript on AWS Lambda • More known for my shitty memes nowadays • Also known for throwing RowToList at everything
  3. Related Haskell talk • Similar talk given earlier this month

    at Helsinki Haskell • Uses GHC Generics to accomplish something similar • Requires defined types everywhere, no row types Is programming without RowToList even programming at all?
  4. Problem • We want to work with INI files •

    INI files are documents of sections of fields [header] key=value • Dio mio, people model this as StrMap (StrMap String)
  5. What do we want • We know exactly which sections

    we want to read from in a document • We know exactly which fields we want to read from in a section • We want a record of records! ◦ And better yet, we want a record alias of record aliases! type Document = { section :: { key :: value } }
  6. Naive approach • We can try to write out concrete

    functions readMyField :: String -> Either Error MyType readMySection :: StrMap String -> Either Error MySection readMyDocument :: StrMap (StrMap String) -> Either Error MyDocument • But being error-prone and boring, why would we? • We can associate section/field names directly to the labels in our record ◦ FAQ: Need to rename? See Record.rename
  7. Docs to Types [section1] fruit=apple isRed=true seeds=4 [MOOMINJUMALA] children=banana,grape,kiwi [麻婆豆腐]

    type TestIni = { section1 :: { fruit :: String , isRed :: Boolean , seeds :: Int } , "MOOMINJUMALA" :: { children :: Array String } , "麻婆豆腐" :: {} }
  8. How? • First we run INI document text body through

    a parser parsellIniDocument :: String -> Either ParseError IniDocument • Then we use the row type information to do stuff readDocumentSections :: RLProxy xs -- from RowToList row xs -> StrMap (StrMap String) -> Except _ _
  9. Tortell[ini] - aka Wontons made by Italians • I wrote

    a library to do this • Use the parsellIni function with a type annotation or concrete context, done! parsellIni :: forall rl row . RowToList row rl => ReadDocumentSections rl () row => String -> Either UhOhSpaghettios { | row }
  10. Mikä on “UhOhSpaghettios”? • We should handle errors up front

    data UhOhSpaghetto = Error String | ErrorAtDocumentProperty String UhOhSpaghetto | ErrorAtSectionProperty String UhOhSpaghetto | ErrorInParsing ParseError • What is the plural of UhOhSpaghetto? type UhOhSpaghettios = NonEmptyList UhOhSpaghetto
  11. • Iterates a RowList and gives back a Record.Builder class

    ReadDocumentSections (xs :: RowList) (from :: # Type) (to :: # Type) | xs -> from to where readDocumentSections :: RLProxy xs -> StrMap (StrMap String) -> Except UhOhSpaghettios ( Builder (Record from) (Record to)) ReadDocumentSections
  12. Wait, what is Record.Builder? • Why keep clumsily making clones?

    newClonesEveryTime = let a = { x: 1 } b = { x: a.x, y: 2 } in Record.insert (SProxy :: SProxy "z") 3 b • What do we know we want? { x :: Int } -> { x :: Int, y :: Int, z :: Int }
  13. Builder! • We want to go from { x ::

    Int } to { x :: Int, y :: Int, z :: Int } • We have two insertion operations • Why can’t we compose them? ◦ Properly like Semigroupoids, not “compose” in some vague sense • You can! compose :: forall f a b c. Semigroupoid f => f b c -> f a b -> f a c composeBuilderAlias :: forall a b c. Builder b c -> Builder a b -> Builder a c
  14. Compose and Build addY :: Builder {x :: Int} {x

    :: Int, y :: Int} addY = Builder.insert (SProxy :: SProxy "y") 2 addZ :: Builder {x :: Int, y :: Int} {x :: Int, y :: Int, z :: Int} addZ = Builder.insert (SProxy :: SProxy "z") 3 addYZ :: Builder {x :: Int} {x :: Int, y :: Int, z :: Int} addYZ = addZ <<< addY xyz :: {x :: Int, y :: Int, z :: Int} xyz = Builder.build addYZ {x : 1}
  15. • Iterates a RowList and gives back a Record.Builder class

    ReadDocumentSections (xs :: RowList) (from :: # Type) (to :: # Type) | xs -> from to where readDocumentSections :: RLProxy xs -> StrMap (StrMap String) -> Except UhOhSpaghettios ( Builder (Record from) (Record to)) ReadDocumentSections
  16. nilReadDocumentSections • At Nil, we no longer have any more

    fields to build • Since Record.Build is a Category, we can return id ◦ i.e. building an empty record is done by applying id to an empty record instance nilReadDocumentSections :: ReadDocumentSections Nil () () where readDocumentSections _ _ = pure id
  17. consReadDocumentSections instance consReadDocumentSections :: ( IsSymbol name , RowToList inner

    xs , ReadSection xs () inner , RowCons name { | inner } from' to , RowLacks name from' , ReadDocumentSections tail from from' ) => ReadDocumentSections (Cons name { | inner } tail) from to where Section is a a pair of name and type { | inner } Then we can get a builder for the fields The section should be in the document The section should be missing from the subtype from’ from the rest of the sections The rest of the document should be read for sections
  18. from’, to

  19. consReadDocumentSections body readDocumentSections _ sm = do case SM.lookup name

    sm of Nothing -> throwError <<< pure <<< ErrorAtDocumentProperty name <<< Error $ "Missing section in document" Just section -> do builder <- withExcept' $ readSection (RLProxy :: RLProxy xs) section let value = Builder.build builder {} rest <- readDocumentSections (RLProxy :: RLProxy tail) sm let first :: Builder (Record from') (Record to) first = Builder.insert nameP value pure $ first <<< rest where nameP = SProxy :: SProxy name name = reflectSymbol nameP withExcept' = withExcept <<< map $ ErrorAtDocumentProperty name
  20. ReadSection • Largely same as ReadDocument class ReadSection (xs ::

    RowList) (from :: # Type) (to :: # Type) | xs -> from to where readSection :: RLProxy xs -> StrMap String -> Except UhOhSpaghettios (Builder (Record from) (Record to)) • But Cons instance uses ReadIniField class ReadIniField a where readIniField :: String -> Except UhOhSpaghettios a
  21. ReadIniField instances • Similar to Simple-JSON ReadForeign instance intReadIniField ::

    ReadIniField Int where readIniField s = maybe (throwError <<< pure <<< Error $ "Expected Int, got " <> s) pure $ fromNumber $ readInt 10 s instance arrayReadIniField :: ( ReadIniField a ) => ReadIniField (Array a) where readIniField s = traverse readIniField $ split (Pattern ",") s
  22. This all looks a bit familiar Surprise, you now know

    how Simple-JSON works! (Also every other RowToList demo I have) class ReadForeignFields (xs :: RowList) (from :: # Type) (to :: # Type) | xs -> from to where getFields :: RLProxy xs -> Foreign -> F (Builder (Record from) (Record to)) instance readFieldsCons :: ( IsSymbol name , ReadForeign ty , ReadForeignFields tail from from' , RowLacks name from' , RowCons name ty from' to ) => ReadForeignFields (Cons name ty tail) from to where
  23. parsellIni parsellIni :: forall rl row . RowToList row rl

    => ReadDocumentSections rl () row => String -> Either UhOhSpaghettios { | row } parsellIni s = do doc <- lmap (pure <<< ErrorInParsing) $ parsellIniDocument s builder <- runExcept $ readDocumentSections (RLProxy :: RLProxy rl) doc pure $ Builder.build builder {} That’s it!
  24. Usage • We can use the testDoc string and TestIni

    type from earlier suite "parsellIni" do test "works" do case parsellIni testDoc of Left e -> failure $ show e Right (result :: TestIni) -> do equal result .section1.fruit "apple" equal result .section1.isRed true equal result .section1.seeds 4 equal result ."MOOMINJUMALA".children ["banana","grape","pineapple"]
  25. Docs to Types [section1] fruit=apple isRed=true seeds=4 [MOOMINJUMALA] children=banana,grape,kiwi [麻婆豆腐]

    type TestIni = { section1 :: { fruit :: String , isRed :: Boolean , seeds :: Int } , "MOOMINJUMALA" :: { children :: Array String } , "麻婆豆腐" :: {} }
  26. Didn’t I say only context is required? • Yep, you

    really only need an annotation or a concretely typed function to set the type by context test "works2" do case parsellIni testDoc of Left e -> failure $ show e Right (result :: {section1 :: {fruit :: String}}) -> do equal result .section1.fruit "apple" test "works3" do let equal' :: {section1 :: {fruit :: String}} -> _ equal' r = equal r.section1.fruit "apple" either (failure <<< show) equal' $ parsellIni testDoc
  27. Back to the Haskell version • Can’t make record type

    aliases ◦ Records are product types with metaselectors • We have to define data types for the whole document and each section instead of being able to define nested records • Which leads us to use GHC Generics to work generically ◦ But without translation to another structure, no coercion between structurally similar (position dependent) ◦ Check out Generics-Lens by Csongor if interested in ways to work with records in PureScripty ways
  28. Row types make the difference! Haskell: data Config = Config

    { section1 :: Section1 } deriving Generic data Section1 = Section1 { apple :: Text } deriving Generic to/from GHC Generics PureScript type Config = { section1 :: { apple :: String } } Record subtypes, Builder, etc HELVETIN HYVÄÄ ei jumalauta
  29. Thanks! • Blog post about both PureScript and the Haskell

    versions github.com/justinwoo/my-blog-posts#dec-28-2017 • Repo github.com/justinwoo/purescript-tortellini/ • Twitter @jusrin00 • More on how you can use row type inference for stuff speakerdeck.com/justinwoo/easy-json-deserialization-with-simple-json-an d-record