Extensible Records Explained

As of March 2020, School of Haskell has been switched to read-only mode.

extensible is a library for extensible data types. It provides extensible product and sum types parameterized by type-level lists.

This tutorial introduces extensible records, an application of the extensible products.

First, we pass a space-separated list of field names to mkField:

{-# LANGUAGE TemplateHaskell, DataKinds, TypeOperators, FlexibleContexts #-}
{-# OPTIONS_GHC -fno-warn-unticked-promoted-constructors #-}

import Data.Extensible
import Control.Lens hiding ((:>))

mkField "name collective cry"

Template Haskell will generate values that represent field names. Don't worry about the atrocious type.

$ stack ghci --package lens --package extensible
> :load tutorial.hs
...
> :t name
name
  :: forall (kind :: BOX)
            (f :: * -> *)
            (p :: * -> * -> *)
            (t :: (Assoc GHC.TypeLits.Symbol kind -> *)
                  -> [Assoc GHC.TypeLits.Symbol kind] -> *)
            (xs :: [Assoc GHC.TypeLits.Symbol kind])
            (h :: kind -> *)
            (v :: kind)
            (n :: Data.Extensible.Internal.Nat).
     (Labelling "name" p, Wrapper h,
      Data.Extensible.Internal.KnownPosition n, Extensible f p t,
      Elaborate "name" (FindAssoc "name" xs) ~ 'Expecting (n ':> v)) =>
     Data.Extensible.Internal.Rig.Optic' p f (t (Field h) xs) (Repr h v)

Now we define a data type that contains a name of an animal, its collective noun, and an onomatopoeia if it exists.

type Animal = Record
  [ "name" :> String
  , "collective" :> String
  , "cry" :> Maybe String
  ]

Record is a type of record that takes a list of pairs of field name (which has a kind Symbol) and value type (*).

Record :: [Assoc Symbol *] -> *
(:>) :: Symbol -> * -> Assoc Symbol *

Let there be a dove and a swan. Construction is similar to a normal list. Nil is an empty record, and (<:) appends a value. @= annotates a value by the field name.

-- (@=) :: FieldName k -> v -> Field Identity (k :> v)

-- infix 1 @=

dove :: Animal
dove = name @= "dove"
  <: collective @= "dule"
  <: cry @= Just "coo"
  <: Nil

swan :: Animal
swan = name @= "swan"
  <: collective @= "lamentation"
  <: cry @= Nothing
  <: Nil

The field names we've defined can be used as lenses.

> swan ^. name
"swan"

We can update fields with the lens operators. A group of swans on the ground is called a bank; let's apply this.

> swan & collective .~ "bank"
name @= "swan" <: collective @= "bank" <: cry @= Nothing <: Nil

Now we can define a function that takes an Animal and makes a phrase.

collectiveOf :: Animal -> String
collectiveOf a = unwords ["a", a ^. collective, "of", a ^. name ++ "s"]
> collectiveOf dove
"a dule of doves"
> collectiveOf swan
"a lamentation of swans"

collectiveOf can be generalized as follows:

collectiveOf :: (Associate "name" String s, Associate "collective" String s)
  => Record s -> String

The argument no longer has to be Animal; this can be used for any records that have name and collective as String fields.

This may look like complex machinery, but the number of constructs needed is just 6: Record, :>, mkField, @=, <:, Nil.

extensible has close affinity with lens and field names are reusable. It's useful as a replacement for standard haskell records.

Another advantage of extensible records is the ease of creating generic functions. The following example is a FromJSON instance of records. It's much smaller than the implementation based on GHC.Generics.

{-# LANGUAGE TemplateHaskell, DataKinds, TypeOperators, FlexibleContexts, UndecidableInstances #-}
{-# OPTIONS_GHC -fno-warn-unticked-promoted-constructors #-}

import Data.Extensible
import Control.Lens hiding ((:>))
import Data.Aeson (FromJSON(..), withObject)
import Data.Proxy
import Data.String
import GHC.TypeLits

instance Forall (KeyValue KnownSymbol FromJSON) xs => FromJSON (Record xs) where
  parseJSON = withObject "Object" $ \v -> hgenerateFor (Proxy :: Proxy (KeyValue KnownSymbol FromJSON))
    $ \m -> let k = symbolVal (proxyAssocKey m) in case v ^? ix (fromString k) of
      Just a -> Field . pure <$> parseJSON a
      Nothing -> fail $ "Missing key: " ++ 

Forall and hgenerateFor are the important points. Forall c xs is a constraint that every element of xs satisfies the constraint c. In this case, it requires that every field name is a type level string and every field type is an instance of FromJSON. hgenerateFor instantiates those constraints and yields a record.

Forall :: (k -> Constraint) -> [k] -> Constraint
hgenerateFor
  :: (Applicative f, Forall c xs) =>
     proxy c
     -> (forall (x :: k). c x => Membership xs x -> f (Field Identity x))
     -> f (Record xs)

Membership xs x is the type of a witness that x is an element of xs. It is used to reify the field name using symbolVal and proxyAssocKey in the example above. Field . pure constructs a field without specifying a concrete field name, unlike @=.

> decode <$> fromString <$> getLine :: IO (Maybe Animal)
{"name": "zebra", "collective": "dazzle", "cry": "whoop"}
Just (name @= "zebra" <: collective @= "dazzle" <: cry @= Just "whoop" <: Nil)

Here is another example of a ToJSON instance.

Using extensible, you should be able to define a generic instance of your own typeclass without pain.