To Lens or not to Lens?

Last modified: 2022-10-16

For my first steps with Haskell I've evaluated options for handling records, including the optics libraries "lens" and "optics-core".


This is not seasoned Haskell advice! I'm still very new to this language and these are my first impressions with records and optics.

If you've landed here because you're thinking about using anything optics-related: Keep reading, maybe you'll discover something you didn't know but do your own research on top!

Haskell is a pretty cool programming language but one thing that's frequently criticised is the syntax for accessing record fields and creating updated copies of a record value. Records are normally defined like this:

data Node = Node
  { nodeID :: NodeID,
    nodePosition :: V2 Double,
    nodeSize :: V2 Double
  }

The above snippet creates a data type called Node with a record constructor. Accessing record fields works like this:

nodeID myNode

Each record field gets a function that takes a value of the record type and returns the value of that specific field. This is a convenient system because field accessors can be used like any other function. However, it bears one major downside: Field names are not scoped to the record type. That means fields live in the same namespace as anything else in the current module, including fields of other records.

data Node = Node
  { id :: NodeID, -- Error: clashes with "id" of "Edge"
    position :: V2 Double,
    size :: V2 Double
  }

data Edge = Edge
  { id :: EdgeID, -- Error: clashes with "id" of "Node"
    start :: NodeID,
    end :: NodeID
  }

Another aspect of records are updates. Records (like any other value in Haskell) are immutable, so you can't just write something like

myNode.id = NodeID "1"

…and modify myNode. Instead, Haskell lets you create an updated copy of a record value with a special syntactical construct. If you wanted to get a value that's equivalent to myNode but with a different value for nodeID, you would use the following expression:

myNode {nodeID = NodeID "1"}

For flat records, that's perfectly fine but once you start nesting records, it can get a bit hairy.

myNode {nodePosition = (nodePosition myNode) {v2X = 20}}

And that's only one level of nesting.

Optics

Smart people have invented something called "lenses", which are part of a more general family of operators called "optics". Lenses, through some dark magic, encapsulate the aspect of selecting a field (including nested fields) and separate it from updating a record or getting a value. That means lenses don't care what they're being used for, can be composed freely and the composite lens can then be used to act on nested fields.

A popular optics library for Haskell is lens, which looks like this when defining a record:

data Node = Node
  { _nodeID :: NodeID,
    _nodePosition :: V2 Double,
    _nodeSize :: V2 Double
  }

makeLenses ''Node

Fields prefixed with an underscore have lenses generated for them. The generated lenses can then be used to access record fields…

myNode ^. nodeID

…and create updated copies.

myNode & nodeID .~ NodeID "1"

Nested usage, as mentioned above, is done by composition.

myNode & nodePosition . v2X .~ 20

& is just a helper function to reverse the order of function application.

myNode & nodeID .~ NodeID "1"

…is equivalent to

(nodeID .~ NodeID "1") myNode

This solves the awkward update situation but it still has the downside of name clashes, since every lens gets its own function. But lens has a solution for that as well: makeFields.

makeFields is an alternative to makeLenses that generates a typeclass-based lens instead (don't ask me how it works) which can be used on any record with a field of the same name. That allows us to have multiple record types with similarly named fields.

data Node = Node
  { _nodeID :: String,
    _nodePosition :: V2 Double,
    _nodeSize :: V2 Double
  }

makeFields ''Node

data Edge = Edge
  { _edgeID :: String,
    _edgeStart :: NodeID,
    _edgeEnd :: NodeID
  }

makeFields ''Edge

The same name can then be used to refer to different record fields:

n ^. iD <> e ^. iD

The auto-generated camelcase is awkward for some fields but that can be customized. Have a look at FieldNamers.

But even makeFields is not without downsides. In order to use makeFields-based lenses in other modules, you have to export the generated typeclasses, which is kind of annoying:

module Foo
  ( Node (..),
    HasID (..),
    HasPosition (..),
    HasSize (..),
  )
where

data Node = Node
  { _nodeID :: NodeID,
    _nodePosition :: V2 Double,
    _nodeSize :: V2 Double
  }

makeFields ''Node

And statically linked copies of HLS, the Haskell Language Server, don't provide auto completion for Template Haskell-generated code. For example, the HLS binary shipped in the Nix package index (which is pretty popular among Haskellers) is affected by this.

GHC features

GHC itself also has a few flags that change the behavior of records. A simple suggestion by Reddit user "arybczak" uses DuplicateRecordFields and OverloadedRecordDot.

OverloadedRecordDot would not strictly be required if you just want multiple record types with similar fields but it helps with avoiding ambiguity. Consider the following example:

{-# LANGUAGE DuplicateRecordFields #-}

data Node = Node
  { id :: String
  }

data Edge = Edge
  { id :: String
  }

foo :: Node -> Edge -> String
foo n e = n & id <> e & id

In foo, the compiler would complain about id being ambiguous between the field selector for Node, the field selector for Edge and the id function in Prelude. OverloadedRecordDot resolves that ambiguity:

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedRecordDot #-}

data Node = Node
  { id :: String
  }

data Edge = Edge
  { id :: String
  }

foo :: Node -> Edge -> String
foo n e = n.id <> e.id

OverloadedRecordDot does not fundamentally change updates though, so you still have long-winded nested updates.

myNode {position = myNode.position {v2X = 20}}

Another GHC feature, OverloadedRecordUpdate, will supposedly bring a similar syntax to record updates in the future but as it stands, it's still experimental and not super comfortable to use.

Also, for what it's worth, I wasn't able to get HLS running with GHC 9.2, which is required for OverloadedRecordDot. I'm not sure why but the version from Nixpkgs only supports GHC 9.0.2 (which is currently the default for the ghc package in Nix), regardless of how I've configured it. (And that does bother me a bit but I didn't want to spend more time figuring this out at the moment.)

Generics-based optics

The Reddit comment linked above also suggests another library called optics-core (which is part of a set of libraries called "optics") in conjunction with GHC.Generic. That results in something similar to makeFields with the lens library above but…

The above example would look like this with optics-core:

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedLabels #-}

import GHC.Generics (Generic)
import Optics.Core ((^.))

data Node = Node
  { id :: String
  } deriving (Generic)

data Edge = Edge
  { id :: String
  } deriving (Generic)

foo :: Node -> Edge -> String
foo n e = n ^. #id <> e ^. #id

Nested updates stay manageable as well.

myNode & #position % #x .~ 20

This is almost ideal but it still has one major downside: If you can only use pre-9.2 GHC, you can't disable generation of field accessor functions (via NoFieldSelectors). DuplicateRecordFields allows you to have multiple record fields of the same name and lenses generally avoid ambiguity but the generated accessor functions can still create ambiguity with other unrelated functions or names (like where bindings). For example, the above id fields could clash with id in Prelude.

So what did I end up choosing?

For the time being, I think I'll stick with vanilla records (without any GHC flags) and all the awkwardness attached to them. It's a low-magic solution and it requires no extra tooling effort. Also, the programs I'll be writing now aren't huge and I don't expect to have many nested records.

If I ever get GHC 9.2 working though, I might switch to optics-core. I'm not sure if it noticeably slows down compilation (because generics are said to be slower than Template Haskell) but I'll just have to try it out. Additionally, if I get Template Haskell working I would also evaluate optics-th, the Template Haskell version of optics.


The discussion for this article on Reddit also contains some useful input.