The 90% you need to know to use optics

Zelenya - Mar 30 '23 - - Dev Community

đŸ“č Hate reading articles? Check out the complementary video, which covers the same content.


The premise is simple. I think people go too crazy with optics. If you want to use optics, here is 90% of what you need:

  • Lenses and traversals;
  • How to compose them;
  • How to create them;
  • Use these four operators: view, set, over, and toListOf.

(Or their alternatives in other language, more on that later).

And that’s it! You get a colossal toolbox and a productivity boost in less than ten functions. The core knowledge is fundamental – it goes beyond libraries and languages.


💡 If you’ve read any optics tutorials before, you might wonder: What about prisms and other optics? Or what about different encodings? They seem to be so important.

No, they are not. Don’t waste your time when getting started. You can pick these up later (if you want to).


We have to start with boring parts. The first step is to pick and install the library. I’ll go with PureScript and profunctor-lenses because it’s less noisy and nicer for demos. But the basic ideas apply to other languages, like Haskell and Scala.

spago install profunctor-lenses
Enter fullscreen mode Exit fullscreen mode

Then we have to prepare the data. Imagine that we’re working on some bar order service – we can start with a simple highball type.


🍾 Highball is a simple long drink with one liquor and one mixer.


type Order =
  { drink :: Highball
  }

type Highball =
  { liquor :: Liquor
  , mixer :: Mixer
  , ounces :: Int
  }

data Liquor = Scotch | Gin

data Mixer = Soda | Tonic
Enter fullscreen mode Exit fullscreen mode

And a couple of instances to show/see what we’re doing:

derive instance Generic Liquor _
instance Show Liquor where
  show = genericShow

derive instance Generic Mixer _
instance Show Mixer where
  show = genericShow
Enter fullscreen mode Exit fullscreen mode

Lens

  • A lens deconstructs product types, such as records and tuples.
  • A lens must always focus on the value.

In our case, we can create a lens that focuses on the drink field (or part) of the Order record and lenses that focus on liquor, mixer, and ounces of Highball. Let’s write a couple.

Disclaimer for Java boilerplate flashbacks. This is the only boilerplate we have to write.

drinkLens :: Lens' Order Highball
drinkLens = prop (Proxy :: _ "drink")

liquorLens :: Lens' Highball Liquor
liquorLens = prop (Proxy :: _ "liquor")
Enter fullscreen mode Exit fullscreen mode

drinkLens is a lens from Order(whole) to Highball(part); we create it by using a function prop and passing it the name of the field drink. We use Proxy because it’s type-level information – if we pass a wrong non-existing field (for example, drank), it won’t compile.

Lens composition

We can already use these, but they're boring and not exciting.

Optics are most useful for nested data structures. One of the most remarkable things about optics is composition. We can compose two lenses to get a “larger” lens.

Let’s compose drinkLens and liquorLens to get a lens from Order to Liquor.

orderedLiquor :: Lens' Order Liquor
orderedLiquor = drinkLens <<< liquorLens
Enter fullscreen mode Exit fullscreen mode

💡 (<<<) is the composition operator in PureScript.


Note that we don’t have to declare intermediate optics (when needed):

orderedOunces :: Lens' Order Int
orderedOunces = drinkLens <<< prop (Proxy :: _ "ounces")
Enter fullscreen mode Exit fullscreen mode

And finally, we can see lenses in use.

Using lenses

We can use lenses to get, set, or modify a value within a structure when we know it exists.

Imagine we have an order:

let myOrder = { drink: { liquor: Scotch, mixer: Soda, ounces: 10 } }
Enter fullscreen mode Exit fullscreen mode

We can use the lens (from Order to Liquor) to get the value using the view function:

view orderedLiquor myOrder 
-- Scotch
Enter fullscreen mode Exit fullscreen mode

We can use the lens to set the value using the set function; for example, to switch to Gin:

set orderedLiquor Gin myOrder
-- { drink: { liquor: Gin, mixer: Soda, ounces: 10 } }
Enter fullscreen mode Exit fullscreen mode

We can use the lens to modify the value using the over function; for example, to bump the drink size:

over orderedOunces (_ + 1) myOrder
-- { drink: { liquor: Scotch, mixer: Soda, ounces: 11 } }
Enter fullscreen mode Exit fullscreen mode

Which might remind you of using a map function.


💡 Note that lenses aren’t limited to the same type, we can use type-changing lenses and operations, but we keep it simple for now.


Quick recap

  • A lens focuses on one part of the structure, such as a field of a record.
  • We can compose two lenses to get another lens (using (<<<)).
  • We can use the props function to create a lens in purescript-profunctor-lenses.
  • We can use view to get, set to set, and over to modify values with a lens.

Traversal

Let’s modify the Order type to allow multiple drinks instead of just one.

type Order =
  { drinks :: Array Highball
  }
Enter fullscreen mode Exit fullscreen mode

The code doesn't compile anymore – we have to fix our lenses.

The drinkLens:: Lens' Order Highball is invalid because the field's name and type have changed. We have to patch it:

-- drinkLens :: Lens' Order Highball
-- drinkLens = prop (Proxy :: _ "drink")

drinksLens :: Lens' Order (Array Highball)
drinksLens = prop (Proxy :: _ "drinks")
Enter fullscreen mode Exit fullscreen mode

The compiler is still not happy, pointing fingers at orderedLiquorLens:

orderedLiquors :: Lens' Order Liquor
orderedLiquors = drinkLens <<< liquorLens
--                             ^^^^^^^^^^
-- Compilation error:
-- Could not match type Record with type Array
Enter fullscreen mode Exit fullscreen mode

It says it could not match the type Record with the type Array:

  • drinksLens goes from Order to Array Highball;
  • liquorLens goes from Highball to Liquor.

So, now we have a “gap” or a mismatch between Array Highball and Highball. We can’t use a lens here because a lens must always focus on the value. But arrays can have one, multiple, or even zero elements (values).

Here is where the traversals come in. Traversal focuses on multiple values in a structure (or collection).

Making and composing traversals

To fix the code, we must add a traversal to the composition and change the type from Lens' to Traversal'. Composing a lens with a traversal or two traversals makes a traversal.

orderedLiquor :: Traversal' Order Liquor
orderedLiquor = drinksLens <<< traversed <<< liquorLens
Enter fullscreen mode Exit fullscreen mode

The traversed function creates a traversal; we can extract it to make this explicit and clear:

orderedLiquor :: Traversal' Order Liquor
orderedLiquor = drinksLens <<< allDrinksTraversal <<< liquorLens

allDrinksTraversal :: Traversal' (Array Highball) Highball
allDrinksTraversal = traversed
Enter fullscreen mode Exit fullscreen mode

Which is more verbose but can be nice for learning and figuring out the types.


And same with the other optic:

orderedOunces :: Traversal' Order Int
orderedOunces = drinksLens <<< traversed <<< prop (Proxy :: _ "ounces")
Enter fullscreen mode Exit fullscreen mode

Using traversals

We can use traversals to modify all values (like map or traverse) or get all the values.


💡 We can also combine all the values into a single value (like fold). But it’s not crucial for now.


Let’s modify the order by adding another drink:

myOrder =
      { drinks:
          [ { liquor: Scotch, mixer: Soda, ounces: 10 }
          , { liquor: Gin, mixer: Tonic, ounces: 10 }
          ]
      }
Enter fullscreen mode Exit fullscreen mode

We can use a traversal (from Order to Liquor) to modify all values using the over function as we did with a lens; for example, to bump all the drink sizes in the order. We don’t even have to change the code:

over orderedOunces (_ + 1) myOrder
-- { drinks: [{ liquor: Scotch, mixer: Soda, ounces: 11 },{ liquor: Gin, mixer: Tonic, ounces: 11 }] }
Enter fullscreen mode Exit fullscreen mode

We can also use a traversal to get all the values using toListOf (or toArrayOf):

toArrayOf orderedOunces myOrder
-- [10,10]

toListOf orderedLiquor myOrder
-- (Scotch : Gin : Nil)
Enter fullscreen mode Exit fullscreen mode

Quick recap

  • A traversal focuses on 0, 1, or many values of the structure (or collection).
  • We can compose lenses and traversals to get traversals.
  • We can use the traversed function to create a traversal.
  • We can use over to modify values with a traversal and toList to get the list of values.

💡 Cheat sheets and references:

Overview


. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player