Introduction to optics: lenses and prisms

Optics are a very useful tool in functional programming. They can reduce the amount of code we have to write significantly, as well as making operations clearer and more readable. We’ll talk about two main types: Lens and Prism. Both propose a way for “getting” and “setting” values in a data type. Lens is for working with product types (e.g. tuples, objects), Prism is for working with sum types (e.g. maybe types, union types) and arrays.

The problem

This tutorial assumes that our data structures are immutable. To see why we might want to consider optics, we’ll look at a simple example. We’ll define two object types

type Street = { num: number, name: string };
type Address = { city: string, street: Street };

Given an instance of Address, getting the street name is quite simple

const a1: Address = 
{ city: 'london', street: { num: 23, name: 'high street' } }
const name = a1.street.name

Setting it to a new value is less so

const a2: Address = {
...a1,
street: {
...a1.street,
name: 'main street'
}
}

As we can see working with values deep within the data structure starts to get awkward.

Lenses

A lens is a first-class reference to a subpart of some data type. Given a lens there are essentially three things you might want to do

  • view the subpart
  • modify the whole by changing the subpart
  • combine this lens with another lens to look even deeper

A lens is nothing more than a pair of functions, a getter and a setter. The type S represents the whole, A the subpart

interface Lens<S, A> {
get(s: S): A,
set(a: A, s: S): S
}

Let’s define a lens for the type Address with focus on the street field

const address: Lens<Address, Street> = {
get: address => address.street,
set: (street, address) => ({ ...address, street })
}
address.get(a1)
// => {num: 23, name: "high street"}
address.set({num: 23, name: 'main street'}, a1)
// => {city: "london", street: {num: 23, name: "main street"}}

Now let’s define a lens for the type Street with focus on the name field

const street: Lens<Street, string> = {
get: street => street.name,
set: (name, street) => ({ ...street, name })
}

Is there a way to get a lens for the type Address with focus on the inner name field?

Composition

The great thing about lenses is that they compose

function composeLens<A, B, C>(ab: Lens<A, B>, bc: Lens<B, C>): Lens<A, C> {

return {
get: a => bc.get(ab.get(a)),
set: (c, a) => ab.set(bc.set(c, ab.get(a)), a)
}
}

Now handling the inner name is trivial

const streetName = composeLens(address, street)
streetName.get(a1)
// => "high street"
streetName.set('main street', a1)
// => {city: "london", street: {num: 23, name: "main street"}}

Note that our lenses can be composed of any number of other lenses.

Modify

Let’s say we need to set the first character of the address street name in upper case. Mapping a function over a part of a data structure given a lens is as simple as get the value, apply the function to the value, set the new value to be the result

function overLens<S, A>(lens: Lens<S, A>, f: (a: A) => A, s: S): S {
return lens.set(f(lens.get(s)), s)
}
function capitalize(s: string): string {
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
overLens(streetName, capitalize, a1)
// => {city: "london", street: {num: 23, name: "High street"}}

Prisms

In the above example, we used capitalize to upper case the first letter of a string. It works but it would be clearer if we could use Lens to zoom into the first character of a string. However, we cannot write such a Lens because a Lens defines how to focus from an object S into a mandatory object A and in our case, the first character of a string is optional as a string might be empty. For this we need a sort of partial Lens.

Prisms work with cases like this. A simple way to put it is that a prism is like a lens, but to something which is or is not there.

Note: sometimes this kind of optics is named Optional.

interface Prism<S, A> {
get(s: S): ?A,
set(a: A, s: S): S
}

As you can see lenses and prisms are very similar, except that the getter in the prism returns a maybe type. Let’s write a prism for the first letter of a string

const first: Prism<string, string> = {
get: s => s ? s.substring(0, 1) : null,
set: (a, s) => s.length ? a + s.substring(1) : ''
}

Similarly to composeLens we can compose prisms obtaining again a prism

function composePrism<A, B, C>(ab: Prism<A, B>, bc: Prism<B, C>): Prism<A, C> {
  return {
get: a => {
const b = ab.get(a)
return b == null ? null : bc.get(b)
},
set: (c, a) => {
const b = ab.get(a)
return b == null ? a : ab.set(bc.set(c, b), a)
}
}
}

All Lens can be seen as Prism where the optional element to zoom to is always present

function overPrism<S, A>
(prism: Prism<S, A>, f: (a: A) => A, s: S): S {
const a = prism.get(s)
return a ? prism.set(f(a), s) : s
}
function toUpper(s: string): string {
return s.toUpperCase()
}
overPrism(composePrism(streetName, first), toUpper, a1)
// => {city: "london", street: {num: 23, name: "High street"}}

Union types

Prisms are applicable to all union types. How would we write a prism for a custom union type of our own?

type Domicile
= { type: 'office', address: Address }
| { type: 'personal', address: string };

Here’s a prism to the address of Domicile when the case is office

const office: Prism<Domicile, Address> = {
get: d => d.type === 'office' ? d.address : null,
set: (address, d) => d.type === 'office' ?
{ type: 'office', address } : d
}
const d1 = { type: 'office', address: a1 }
const d2 = { type: 'personal', address: '23 high street' }
office.get(d1)
// => {city: "london", street: {num: 23, name: "high street"}}
office.get(d2)
// => null

We now have (in the combination of lenses and prisms) some powerful, general and predicatable tools with which to work on data.