Skip to content

Introduction to composable optics with monocle-ts

Optics are a functional programming toolbox for zooming into nested data structures. They are designed for composability, allowing you to create complex operations step-by-step by composing simple components. Optics also never modify their input data structures, ensuring your objects stay nice and immutable.

Giulio Canti's monocle-ts library is a TypeScript port of Scala's Monocle library, which in turn is inspired by Haskell's Lens library. It provides "a highly generic toolbox for composing families of getters, folds, isomorphisms, traversals, setters and lenses and their indexed variants."

In this first article, we'll use two optics: lenses and optionals. In the next article, we'll dive deeper into traversals, isomorphisms, and prisms.

We'll use the io-ts library for defining the types for our examples. Using io-ts is a small detour and not actually required for the first article, but we'll see later how it can work together with optics such as Prism to zoom into values of specific type.

The code for this tutorial can be found in the monocle-ts folder of this repository.

Getting started with io-ts

io-ts is a run-time type system. It allows you to add run-time type-checking to those pesky Any objects you get from external sources like user inputs, files, or databases. Let's consider a simple Hobby interface defined as follows:

interface HobbyI {
  name: string
}

The way to define this in io-ts is as follows:

import * as t from "io-ts"
const HobbyT = t.interface({ name: t.string })
type Hobby = t.TypeOf<typeof HobbyT> // Static type

I use the T extension to mark io-ts types. It's important to notice that the HobbyT is an object and not a type: it remembers its properties even after the TypeScript code is transpiled to JavaScript. Therefore, one can use the HobbyT object at run-time to check if objects are actually valid hobbies or not.

Hobby, on the other hand, is a static type equivalent to type Hobby = { name: string }. Hobby only lives in TypeScript and does not exist anymore after transpilation.

HobbyT has an is method you can use to check if objects are valid hobbies:

it("accepts an valid hobby object as HobbyT", () => {
  const isHobby = HobbyT.is({ name: "Photographing corgis" })
  expect(isHobby).toBe(true)
})
it("does not accept an invalid hobby object as HobbyT", () => {
  const isHobby = HobbyT.is({ name: 66 })
  expect(isHobby).toBe(false)
})

For more stringent validation and error messages, you can use decode:

import { isLeft, isRight } from "fp-ts/lib/Either"

it("can decode a hobby from valid input", () => {
  const maybeHobby = HobbyT.decode({ name: "Petting corgis" })
  expect(isRight(maybeHobby)).toBe(true)
})
it("does not decode a hobby from invalid input", () => {
  const maybeHobby = HobbyT.decode({ name: 67 })
  expect(isLeft(maybeHobby)).toBe(true)
})

decode method returns an Either object, whose value can be "left" or "right" corresponding to either failure or success, respectively. If there's an error, the either contains a "left" of t.Errors type defined as follows:

export interface Errors extends Array<ValidationError> {}

Validation errors can be printed with, for example, the PathReporter utility. You can read more about the Either type in my previous article on fp-ts.

Here are the rest of the types we'll need:

const PersonT = t.interface({
  firstName: t.string,
  age: t.number,
  hobbies: t.array(HobbyT),
})
type Person = t.TypeOf<typeof PersonT>

const BandT = t.interface({ name: t.string, members: t.array(PersonT) })
type Band = t.TypeOf<typeof BandT>

Person is an object with firstName, age and an array of hobbies. A band is an object with name and members, where members is a list of persons.

We also define a few objects we'll work with:

const elvis: Person = {
  firstName: "Elvis",
  age: 100,
  hobbies: [
    {
      name: "singing",
    },
  ],
}

const metallica: Band = {
  name: "Metallica",
  members: [
    {
      firstName: "James",
      hobbies: [],
      age: 56,
    },
    {
      firstName: "Lars",
      hobbies: [],
      age: 55,
    },
    {
      firstName: "Kirk",
      hobbies: [],
      age: 57,
    },
    {
      firstName: "Robert",
      hobbies: [],
      age: 55,
    },
  ],
}

Elvis is a single person and Metallica is a band with five members.

Lenses

We'll start with Lens, which is a composable getter and setter. As customary in functional programming, we start by looking at the type signature to understand what's going on:

export class Lens<S, A> {
  constructor(readonly get: (s: S) => A, readonly set: (a: A) => (s: S) => S) { ... }
  ...
}

We see that the constructor takes get and set functions as input arguments. Type variables S and A stand for the types of the container we apply our lens to and the type of object in S we zoom into, respectively. The getter consumes an object of type S and produces an object of type A. The setter is a curried function taking a new value a of type A and the object of type S to use the setter on. It returns a new object of type S with new value a included.

Lenses can be created with Lens.fromProp function. Here's a full example of a lens personToName of type Lens<Person, string>:

const personToName: Lens<Person, string> = Lens.fromProp<Person>()("firstName")

Type signature Lens<Person, string> means that the lens operates on objects of type Person and targets a field of type string. Lens.fromProp requires explicitly setting the type variable Person, but it can infer the type string from the type of the field to zoom into (firstName). Other ways to create lenses from scratch are the static fromPath, fromProps and fromNullableProp methods of the Lens class. You can also use LensFromPath.

The lens getter (p: Person) => string can be accessed via get property:

const getName: (p: Person) => string = (p: Person) => personToName.get(p)
expect(getName(elvis)).toEqual("Elvis")

Here's how you could use the personToName.set as a setter:

const setName: (newName: string) => (p: Person) => Person = personToName.set
const setJillAsName: (p: Person) => Person = setName("Jill")
const modified: Person = setJillAsName(elvis)
expect(modified).toHaveProperty("firstName", "Jill")
expect(elvis).toHaveProperty("firstName", "Elvis") // Unchanged

Note that elvis object remains intact as the setter does not modify its input.

With the modify method you can create a setter that modifies fields with the given function:

const upperCase = (s: string): string => s.toUpperCase()
const upperCasePersonName: (p: Person) => Person =
  personToName.modify(upperCase)
const elvisUpperCased = upperCasePersonName(elvis)
expect(elvisUpperCased).toHaveProperty("firstName", "ELVIS")

This all nice and good, but the true power of optics becomes clear when you start to compose them. We'll see examples of this soon when introducing new optics.

Optional

Optional is an optic for zooming into values that may not exist. The signature is as follows:

export class Optional<S, A> {
  constructor(readonly getOption: (s: S) => Option<A>, readonly set: (a: A) => (s: S) => S) { ... }
  ...
}

Similarly to Lens, Optional is a generic class with two type variables S and A. Also similarly to Lens, the constructor of Optional has input arguments for getter and setter methods, with the exception that the getOption returns an Option<A>. Option is a container that either contains a value of type A or is empty. For an introduction to Option, see fp-ts documentation. Be careful not to confuse the type class Option with the optic Optional!

Like Lens, also Optional has many alternatives for constructing one: fromPath, fromNullableProp, fromOptionProp, and OptionalFromPath. There are good examples in the documentation for how to use them.

For practice purposes, let's construct an Optional from scratch. We create an Optional that allows accessing the first member of the band. Assuming we allow bands that have no members at all, the first band member may not exist, so we want to safely handle that situation.

Remember we defined our band type as follows:

type Band = {
  name: string
  members: Person[]
}

Assume that we already have our members field of type Band, and now we want to access the first member. A function returning the first value of an array is typically called head. The type signature for head should then be Optional<Array<Person>, Person>. The constructor first takes a getOption method of type (persons: Person[]) => Option<Person>. Here's how we'd safely get the first member of the band:

import { some, none } from "fp-ts/lib/Option"

const getOption: (ps: Person[]) => Option<Person> = (personArray: Person[]) =>
  personArray.length === 0 ? none : some(personArray[0])

The helper functions none and some allow creating options with empty and non-empty values, respectively.

Now we need to define the set function for our Optional<Array<Person>, Person>. The required signature is set: (p: Person) => (ps: Person[]) => Person[]. What is set supposed to do? It should set a person as the first member of the array if the array is not empty. Here's our implementation:

const set: (p: Person) => (ps: Person[]) => Person[] =
  (p: Person) => (ps: Person[]) => (ps.length === 0 ? [] : [p, ...ps.slice(1)])

It's very important to notice here what set does not do. First, it does not add the given person to the array if the array is empty. Optional should only work as a setter when the target value would be non-empty. If the target value is empty, the setter should be no-op. Second, set does not prepend given person to the array but replaces the old value with the new value, therefore keeping the length of the list intact.

How's one supposed to know what set is supposed to do? The answer lies in optics laws. To be properly composable, every optic implementation must obey specific laws. For Optional, the laws for getOption and set are

  1. getOption(s).fold(() => s, a => set(a)(s)) = s
  2. getOption(set(a)(s)) = getOption(s).map(_ => a)
  3. set(a)(set(a)(s)) = set(a)(s)

The first two laws essentially ensure that getOption and set are "inverse" operations. The last one states that set is idempotent.

If our set function from above added (p: Person) to an empty array, the second law would be violated for empty s. If our set prepended the given value to the existing array, the third law would be violated. We won't go deeper into laws of optics in this article, but beware: when rolling out your own optics, make sure that the laws hold. You may want to use a property based testing library such as fastcheck to be sure.

Now we're ready to define head zooming into the first value of an array of persons. Here's the full definition:

const getOption: (ps: Person[]) => Option<Person> = (personArray: Person[]) =>
  personArray.length === 0 ? none : some(personArray[0])
const set: (p: Person) => (ps: Person[]) => Person[] =
  (p: Person) => (ps: Person[]) => (ps.length === 0 ? [] : [p, ...ps.slice(1)])
const head: Optional<Array<Person>, Person> = new Optional<
  Array<Person>,
  Person
>(getOption, set)

To apply our new Optional on a band, let's compose it with the members Lens:

const membersLens = Lens.fromProp<Band>()("members")

const bandToFirstMember: Optional<Band, Person> =
  membersLens.composeOptional(head)

We've written our first optics composition! Compositions are written with composeX methods of optics.

Let's ensure our composed optic works as a getter for a band containing members:

expect(bandToFirstMember.getOption(metallica)).toEqual(
  some(
    expect.objectContaining({
      firstName: "James",
    })
  )
)

The getOption returns the first member of the band wrapped in Option as expected. Let's try it on an empty band:

const bandWithNoMembers: Band = {
  name: "Unknown",
  members: [],
}
expect(bandToFirstMember.getOption(bandWithNoMembers)).toEqual(none)

In this case getOption returns a none as expected. Let's go even further and compose bandToFirstMember with a lens zooming into the firstName property and use it to modify the name:

const nameLens = Lens.fromProp<Person>()("firstName")
const nameOptional: Optional<Band, string> =
  bandToFirstMember.composeLens(nameLens)

const upperCase = (s: string): string => s.toUpperCase()

const upperCaseFirstBandMemberName = nameOptional.modify(upperCase)

expect(upperCaseFirstBandMemberName(metallica).members).toContainEqual(
  expect.objectContaining({
    firstName: "JAMES",
  })
)

See the test file in the accompanying repository for an example optic zooming into the oldest member of the band.

Optional allows one to zoom into values that may not exist. In the next article, we'll see how to use Traversal and Fold to zoom into multiple values (like all members of the band).

Conclusion

That concludes our introduction to optics with monocle-ts! Please leave a comment if you made it all the way to the end, I appreciate all feedback.

Finally, I'd like to mention that I think Giulio Canti's functional programming libraries (fp-ts, monocle-ts, io-ts, hyper-ts) all make very good repositories for contributions. Documentation can be quite terse and I think the author is very open to making the packages more easy to approach for newcomers. So if you're reading the documentation and find that a killer function is missing documentation, shoot a pull request with your own example! I did it too, once :)

Resources