Lenses with OverloadedRecordDot
Not wanting to let the 6+ gigabytes of storage required for my
haskell.nix
-provided GHC 9.2.1 shell to go to waste, I immediately set to work
using one of the new goodies it provides; GHC 9.2.1 introduced 3 new language
extensions, 2 of which (OverloadedRecordDot
, and NoFieldSelectors
) are of
interest here.
NoFieldSelectors
is great because we can finally put automatically generated
record field accessors in the bin forever. The upshot is much less clutter with
variable and record field names. Hurray!
In addition we have a very powerful extension in OverloadedRecordDot
. With
this you can now access fields on some data like in other – less civilised –
languages, where accessing a field involves merely using the name of the field
after a dot after the so-called object. There’s a huge advantage here, in that
the namespace for what comes after the dot is only as big as the number of
public fields on the object. We Haskellers are currently accustomed to suffering
through qualified imports of record fields, or strangely prefixed record field
names, or awkward label syntax and extra composition operators.
But there is a drawback. What happens to all my code that’s been using lens
or
optics
? It’s fairly idiomatic in the lens
extended universe to write field
compositions as just field1.field2.field3
, but now with OverloadedRecordDot
enabled, .
used without spaces around it is no longer the (.)
operator from
Prelude
, but a special field accessing operator introduced by
OverloadedRecordDot
. obj.field
is equivalent now to getField @"field" obj
;
obj.a.b
would be getField @"b" (getField @"a" obj)
– so we can see how this
doesn’t really make sense with optics, as the fields are no longer really
“first-class” objects but depend on whatever we are accessing.
You can get lenses out of a stone
We can still make this work. Let’s see what the definition for HasField
is:
>>> :i getField
type HasField :: forall {k}. k -> * -> * -> Constraint
class HasField x r a | x r -> a where
getField :: r -> a
-- Defined in `GHC.Records'
We can define our own instances for this class that GHC will happily accept –
it isn’t a class where only it is permitted to provide the instances
automatically (a la Coercible
).
The same idea has been implemented before in JavaScript/TypeScript land, where
there is a Proxy
object that lets you override the absolute anything out of
almost everything. OverloadedRecordDot
enables many of the same (pretty wild)
possibilities.
Some prior art using Proxy
for optics:
- https://github.com/aynik/proxy-lens
- https://github.com/hatashiro/lens.ts
- https://github.com/yelouafi/focused
So what follows is pretty much that, but for Haskell, using lens
or optics
, and
OverloadedRecordDot
.
First, we can define the type that will encapsulate our new “first class field” type. It’ll just be a unit type carrying around a type-level list of symbols, morally representing a bunch of fields composed with each other.
-- I recommend to put GHC extensions all on one line so that it's harder to
-- count how many extensions your file requires
{-# LANGUAGE AllowAmbiguousTypes, DataKinds, OverloadedRecordDot, ScopedTypeVariables, TypeApplications, UndecidableInstances, DeriveGeneric, TypeFamilies #-}
data L (fields :: [Symbol]) = L deriving (Show)
ltail :: L (x ': xs) -> L xs
= L
ltail _
Now, note that OverloadedRecordDot
won’t work on data constructors as GHC will
see them as modules first. So, define a lowercase synonym for L
as well. A nice
bonus is we can fix it to be of the empty list type instead of any old type
unifying with L xs
.
l :: L '[]
= L l
Defining HasField
is easy:
instance HasField x (L xs) (L (x ': xs)) where
= L getField _
Which gives us some pretty nifty capabilities:
>>> :t l.asdf
.asdf :: L '["asdf"]
l>>> :t l.foo.bar
.foo.bar :: L '["bar", "foo"]
l>>> :t l.foo.bar.baz
.foo.bar.baz :: L '["baz", "bar", "foo"] l
Now, basically, all that’s left is a way to turn a L
into an optic. This will
work with both lens
and optics
style optics. The trick is to utilise some code
previously written that was intended for use with the OverloadedLabels
but works
perfectly here as well. For lens
, generic-lens
gives us that ability to turn a
type-level Symbol
into a lens, and optics
has the same functionality built-in
through its LabelOptic
typeclass.
lens
version
class LToLens xs s t a b where
asLens :: L xs -> Lens s t a b
instance (a ~ s, b ~ t) => LToLens '[] s t a b where
= id
asLens _
instance
(Field x u v a b,
LToLens xs s t u v
=> LToLens (x ': xs) s t a b where
) =
asLens l .
asLens (ltail l) @x @u @v @a @b fieldLens
optics
version
class LToOptic xs k s t a b where
asOptic :: L xs -> Optic k NoIx s t a b
-- Unsure how needed the A_Lens constraint here is or if there's a better alternative
instance (a ~ s, b ~ t, k ~ A_Lens) => LToOptic '[] k s t a b where
= Optic id
asOptic _
instance
(LabelOptic x k u v a b,
LToOptic xs l s t u v,
JoinKinds l k m
=> LToOptic (x ': xs) m s t a b where
) =
asOptic l @xs @l @s @t @u @v (ltail l) %
asOptic @x @k @u @v @a @b labelOptic
Putting it to use
data A = A { a :: B } deriving (Show, Generic)
data B = B { b :: C } deriving (Show, Generic)
data C = C { c :: Int } deriving (Show, Generic)
test :: A
= A (B (C 42))
test
= test ^. asLens l.a.b.c
test1 -- test1 = 42
= (test & asLens l.a.b.c .~ 43) ^. asLens l.a.b.c
test43 -- test43 = 43
-- basic type-changing update
data Test a = Test { test :: a }
deriving (Generic, Show)
typechanging0 :: Test Int
= Test 23
typechanging0
typechanged :: Test ()
= typechanging0 & asLens l.test .~ ()
typechanged
nested :: Test (Test (Test Char))
= Test (Test (Test ())) & asLens l.test.test.test .~ 'a' nested
Going further
Do some type-level parsing in the GHC.Records.HasField
instance to enable:
Prisms, i.e. fields beginning with
/_[A-Z]/
. (Note actual fields can do that as well, but realistically, who cares?)An escape hatch out of the
L
type. That is, accessing, for instance,l.lens
, could have the same effect as callingasLens
. Alternatively, have an escape hatch straight into getters and setters, or even aLens
“object” that supports many different operations on it. You could really go wild with syntactically approximating OOP with this extension.
Should anyone use this? Honestly, I dunno. There’s already so many different options for records in Haskell that yet another is maybe … perfectly ok. I haven’t packaged this up because I can’t use GHC 9.2.1 in production anyway due to previous changes in the 9.x series holding back many packages that now need various changes. GHC 8.10.7 is, basically, good enough for any production use, and this sort of stuff sadly falls in the “nice to have” basket.
blog comments powered by Disqus