This is a plugin based library that facilitates using the higher-kinded data pattern with record types without adding parameters or using generics.
The plugin allows for record syntax to be used to instantiate the higher kinded version of a record, providing good ergonomics while maintaining the familiarity of constructing records the standard way.
Another benefit of this plugin over a generics based approach is that there is far less compile time overhead. Generics code is a well known culprit of slow compilation due to the strain it puts on the simplifier. The use of a plugin largely sidesteps this issue.
To use this library, simply add hi-fi
as a package dependency and then add
the following to the ghc-options
stanza of your package.yaml
or *.cabal
file to enable the plugin:
ghc-options: -fplugin HiFi
Everything you need is exported by the HiFi
module. This module can be
imported either qualified or unqualified, but using an explicit import list for
it is not recommended.
The special mkHKD
function is used to instantiate the higher kinded data
version of a record. It is used with record syntax to explicitly assign each
field of the record:
data MyRecord =
MkMyRecord
{ foo :: Bool
, bar :: Int
, baz :: String
}
-- A higher kinded data version of 'MyRecord' using the 'Maybe' functor.
recordUpdate :: HKD MyRecord Maybe
recordUpdate =
mkHKD { foo = Just True
, bar = Nothing
, baz = Just "new"
}
If a field is missing or if there is an extra field, then you will get a custom error message calling out the field by name. Likewise, if the type of a field is incorrect, the error message will contain a reference to the offending field.
If GHC is not able to infer the type of the HKD (or if you prefer to give the
type explicitly) then the TypeApplications
extension can be used to supply
the record type as well as the functor type like so:
{-# LANGUAGE TypeApplications #-}
hkd1 =
(mkHKD @MyRecord)
{ foo = Just True
, bar = Just 1
, baz = Nothing
}
hkd2 =
(mkHKD @MyRecord @Maybe)
{ foo = pure True
, bar = pure 1
, baz = empty
}
Converting to and from the higher kinded version of a record is done using the
toHKD
and fromHKD
functions respectively. In order to use fromHKD
, the
HKD must be using Data.Functor.Identity
as the functor parameter.
hkd :: HKD MyRecord Identity
hkd = toHKD (MyRecord { foo = True, bar = 1, baz = "..." })
record :: MyRecord
record = fromHKD hkd
The hkdSequence
function allows you to turn an HKD parameterized by an
Applicative
effect into the base record wrapped in that effect. As the name
suggests, it is analogous to the sequenceA
function.
recordList :: [MyRecord]
recordList = hkdSequence $
mkHKD { foo = [True, False]
, bar = [1, 2, 3]
, baz = ["..."]
}
The HKD
type is equipped with HasField
instances for each field in the base
record, which means you can use the OverloadedRecordDot
extension to access
fields:
{-# LANGUAGE OverloadedRecordDot #-}
recordUpdate.foo :: Maybe Bool
recordUpdate.bar :: Maybe Int
recordUPdate.baz :: Maybe String
Use the setField
function for setting field values:
modifiedRecordUpdate :: HKD MyRecord Maybe
modifiedRecordUpdate = setField @"foo" Nothing recordUpdate
Additionally, there is a lens called atField
which can be used with most
optics libraries.
modifiedRecordUpdate :: HKD MyRecord Maybe
modifiedRecordUpdate = recordUpdate & atField @"foo" ?~ True
A rich API for working with HKDs is provided. There are also many useful type
class instances for the HKD
type, such as Show
, Eq
, Ord
, Semigroup
,
and Monoid
.
data Person =
MkPerson
{ name :: String
, age :: Int
} deriving Show
sara :: Person
sara = MkPerson "Sara" 40
tom :: Person
tom = MkPerson "Tom" 36
update :: HKD Person Maybe
update = mkHKD {name = Just "Bob", age = Nothing}
-- Example GHCi session:
>>> hkdDistribute [sara, tom]
HKD {name = ["Sara","Tom"], age = [40,36]}
>>> hkdSequence $ hkdDistribute [sara, tom]
[ MkPerson {name = "Sara", age = 40}
, MkPerson {name = "Sara", age = 36}
, MkPerson {name = "Tom", age = 40}
, MkPerson {name = "Tom", age = 36}
]
>>> hkdPure @Last sara <> hkdPure tom
HKD {name = Last {getLast = Just "Tom"}, age = Last {getLast = Just 36}}
>>> hkdApplyUpdate update tom
MkPerson {name = "Bob", age = 36}
Applicative
effects are commonly applied to records using syntax such as
Foo <$> expr1
<*> expr2
<*> expr3
...
This idiom has a number of down-sides:
- It looks bizarre to the uninitiated.
- You must know the order of fields in the record to tell which expression is mapping to which field. It's a common mistake to get the ordering wrong, which won't be caught at compile time if the fields are of the same type.
- The error messages resulting from missing fields or mismatched types can be verbose and confusing.
using hi-fi
you can instead write applicative expressions using record syntax:
hkdSequence (mkHKD @Foo) -- type application optional in most cases
{ field1 = expr1
, field2 = expr2
, field3 = expr3
...
}
This is advantageous because having field assignments greatly improves the readability of the code. You also don't need to worry about putting fields in a particular order. Additionally, error messages will reference the relevant field by name.
The hi-fi
plugin provides a number of type classes that are able to tap into
GHC's internal representation in order to work with records in ways that are
not normally possible without meta programming techniques such as generics or
template haskell.
As an example, let's see how hi-fi
can be used to implement the
FromNamedRecord
and ToNamedRecord
type classes from the cassava
library
in a record generic way.
import qualified Data.Csv as Csv
import Data.Functor.Identity
import Data.Functor.Const
import qualified HiFi
newtype HkdCsv record = MkHkdCsv record
instance ( HiFi.FoldFields (HiFi.WithHkdFields (HiFi.OverFieldTy Csv.FromField Identity) Identity) record Identity
, HiFi.ToRecord record
)
=> Csv.FromNamedRecord (HkdCsv record) where
parseNamedRecord m =
let lookupField fieldName _ _ = Csv.lookup m (fromString fieldName)
in MkHkdCsv . HiFi.fromHKD
<$> HiFi.withInstances @(HiFi.OverFieldTy Csv.FromField Identity) lookupField
instance ( HiFi.FoldFields (HiFi.WithHkdFields (HiFi.OverFieldTy Csv.ToField Identity) Identity) record Identity
, HiFi.FieldGetters record
)
=> Csv.ToNamedRecord (HkdCsv record) where
toNamedRecord (MkHkdCsv rec) =
let hkd = toHKD rec
mkField fieldName getter _ =
Const [ Csv.namedField (fromString fieldName) (getter hkd) ]
in Csv.namedRecord . getConst
$ HiFi.withInstances @(OverFieldTy Csv.ToField Identity) mkField
data Person =
MkPerson
{ name :: String
, age :: Int
} deriving Csv.FromNamedRecord via (HkdCsv Person)
deriving Csv.ToNamedRecord via (HkdCsv Person)
It's possible to have a record as a field in another record and have the inner
record promoted to its higher kinded data version along with the parent record.
This is done using the NestHKD
type, which is a newtype wrapper akin to
Data.Functor.Identity
. To avoid extraneous noise, the plugin will
automatically wrap or unwrap NestHKD
values, so you don't have to deal with
the NestHKD
constructor at the value level.
data Outer = Outer
{ foo :: NestHKD Inner
, bar :: Bool
}
data Inner = Inner
{ baz :: String
}
hkd :: HKD Outer Maybe
hkd = mkHKD
{ foo = mkHKD
{ baz = Just "inner record"
}
, bar = Nothing
}
- Currently supports GHC 9.2.x, 9.4.x, and 9.6.x
- Records must follow certain rules to be promotable to HKDs:
- Nested records cannot result in inifinite recursion
- Types of nested records cannot be type family applications
- Existential type variables or constraint contexts are not allowed