λ Fun With Functions

What Are Lenses For?

Posted on July 11, 2023

What Are Lenses?

First we need to do a little (or more) scene setting, if we want to represent a bunch of achievements for a bunch of users in some games a super simple representation of that in JavaScript might involve some code like this:

users[userIDToUpdate]
  .games['bestgameever']
  .achievements['completedthegame']
  .completedDate = new Date()

This code sets the completed date for the achievement 'completedthegame' for the game 'bestgameever' for the user with some identifier userIDToUpdate. There’s two kinds of access going on here, that of known fields like .games and .achievements, as well as the object key indexing done with square brackets which might address values that don’t exist (for example ['bestgameever']). That’s problem no. 1 though, if any of these ids are wrong the code blows out with a nasty looking exception.

This becomes a lot harder if the goal is to immutably update these values:

const user = users[userIDToUpdate]
const game = user.games['bestgameever']
const achievement = game.achievements['completedthegame']
const updatedAchievement = {
  ...achievement,
  completedDate: new Date()
}
const updatedGame = {
  ...game,
  achievements: {
    ...game.achievements,
    ['completedthegame']: updatedAchievement
  }
}
const updatedUser = {
  ...user,
  games: {
    ...user.games,
    ['bestgameever']: updatedGame
  }
}
const updatedUsers = {
  ...users,
  [userIDToUpdate]: updatedUser
}

We’ve still got the problem of invalid values throwing exceptions but also about 10 times the code.

Lets move this to Haskell where we still have the same kind of problem.

Here’s some setup for language options and imports upfront:

{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE FlexibleContexts #-}
import GHC.Generics
import qualified Data.HashMap.Strict as M
import Data.Time.Clock
import Control.Lens
import Data.Generics.Product
import Data.Generics.Sum

Our basic data types to represent everything:

data Achievement = Achievement { completedDate :: Maybe UTCTime } deriving (Eq, Show, Generic)
data Game = Game { achievements :: M.HashMap String Achievement } deriving (Eq, Show, Generic)
data User = User { games :: M.HashMap String Game } deriving (Eq, Show, Generic)
type Users = M.HashMap String User

Now the starting values:

defaultAchievement = Achievement Nothing
defaultGame = Game (M.singleton "completedthegame" defaultAchievement)
defaultUser = User (M.singleton "bestgameever" defaultGame)
defaultUsers = M.singleton "sean" defaultUser

Updating this is a whole mess of pain:

updateAchievement completedAt achievement =
  achievement { completedDate = Just completedAt }
updateGame completedAt game = game { achievements =
  M.adjust (updateAchievement completedAt) "completedthegame" (achievements game) }
updateUser completedAt user = user { games =
  M.adjust (updateGame completedAt) "bestgameever" (games user) }
getManuallyUpdatedUsers completedAt =
  M.adjust (updateUser completedAt) "sean" defaultUsers

That’s quite verbose and just not very pleasant to read, it wont throw any exceptions if a value is missing however and will instead return the original value. Which is a little bit better but not that great overall.

With lenses though it’s possible to split apart the “targeting” from the operation being performed, first we create a lens which handles the former:

updateCompletedDateLens = at "sean"
                        . _Just
                        . field @"games"
                        . at "bestgameever"
                        . _Just
                        . field @"achievements"
                        . at "completedthegame"
                        . _Just
                        . field @"completedDate"

In this case we’re chaining lenses and there’s a lot of clarity as to what is going on, at is used to peer into the HashMap values and field peers into the the fields of the records.

To update this we use one of the utility functions from the lens library itself:

getLensUpdatedUsers completedAt = set updateCompletedDateLens (Just completedAt) defaultUsers

This snippet runs the Haskell of this post:

main = do
  print defaultUsers
  completedAt <- getCurrentTime
  print $ getManuallyUpdatedUsers completedAt 
  print $ getLensUpdatedUsers completedAt