Lenses with OverloadedRecordDot

Posted on February 23, 2022 by Mike Ledger

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

The average Haskell programmer is an incredible polyglot, knowing upwards of a hundred LANGUAGEs

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:

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
ltail _ = L

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
  getField _ = L

Which gives us some pretty nifty capabilities:

>>> :t l.asdf
l.asdf :: L '["asdf"]
>>> :t l.foo.bar
l.foo.bar :: L '["bar", "foo"]
>>> :t l.foo.bar.baz
l.foo.bar.baz :: L '["baz", "bar", "foo"]

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
  asLens _ = id

    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) .
    fieldLens @x @u @v @a @b

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
  asOptic _ = Optic id

    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 =
    asOptic @xs @l @s @t @u @v (ltail l) %
    labelOptic @x @k @u @v @a @b

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
test = A (B (C 42))

test1 = test ^. asLens l.a.b.c
-- test1 = 42

test43 = (test & asLens l.a.b.c .~ 43) ^. asLens l.a.b.c
-- test43 = 43

-- basic type-changing update
data Test a = Test { test :: a }
  deriving (Generic, Show)

typechanging0 :: Test Int
typechanging0 = Test 23

typechanged :: Test ()
typechanged = typechanging0 & asLens l.test .~ ()

nested :: Test (Test (Test Char))
nested = Test (Test (Test ())) & asLens l.test.test.test .~ 'a'

Going further

Do some type-level parsing in the GHC.Records.HasField instance to enable:

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