Optics in TypeScript

tl;dr Use monocle-ts

Mike Solomon
Sep 20, 2019 · 6 min read

When one of my team members shared this Medium story about lenses, I thought it would be great to do a nice, lightweight exercise in optics (the category to which lenses belong) that my team could get behind. So, I rewrote the entire code base to use optics and submitted it as a giant pull request. So much for best intentions. But I did learn a lot about optics, as did my team, and I would like to share my learnings here. By the end of the article, you should know why one would use optics, what optics are and how to use them.

Why optics

const string withF = (s: string[]): string =>
s.map(i => "F"+i.slice(1))[0];

This code has two major flaws: we are not guaranteed that the strings will be of non-zero length, and we are not sure that the array will be of non-zero length. We could remedy this with the following.

const string withF = (s: string[]): string | undefined =>
s.length > 0 ? s.map(
i => i.length > 0 ? "F"+i.slice(1) : undefined
)[0] : undefined;

While this works, it is unreadable. We have diluted the intention of our code in a sea of conditionals and undefined.

Optics give you three big advantages compared to the hot mess above:

  • They are readable.
  • They allow you to focus on each step of a complex operation in isolation, which helps eliminate bugs.
  • They create composable bits of logic that can be combined into powerful abstractions.

As a basic rule of thumb, any time you are getting a property of an object that may be undefined, which includes anything in a Record, Array, or string, use optics. Also, if you are creating a new object from an old one, use optics. So, basically, use optics all the time.

What are Optics

Lens

import { Lens } from "monocle-ts";type Person = {
name: string;
hobbies?: string[];
}
const nameLens = new Lens<Person, string>(
person => person.name,
name => person => ({ ...person, name }) )
);
const newPerson = nameLens.modify(_ => "Jane")({ name: "Steve" });
newPerson.name // "Jane"

In general, the syntax for a Lens is:

<TopLevel, ThingToFocusOn>(
getter: (a: TopLevel) => ThingToFocusOn,
setter: (a: ThingToFocusOn) => (b: TopLevel) => TopLevel
)

Because lenses are often used to zoom in on the field of an object, monocle-ts provides us with the Lens.fromProp method that simplifies the above definition. In this case, we’d use the following.

import { Lens } from "monocle-ts";type Person = {
name: string;
hobbies: string[];
}
const nameLens = Lens.fromProp<Person>()("name");

Iso

import { Iso } from "monocle-ts";type Person = {
name: string;
hobbies?: string[];
}
type Coder = Person & { languages?: string[]; }const personToCoder = new Iso<Person, Coder>(
person => person,
coder => { { languages, ...rest } = coder; return rest; }
);
const newCoder = personToCoder.modify(_ => _)({ name: "Steve" });
newCoder.name // "Steve"

In general, the syntax for an Iso is:

<ThingA, ThingB>(
get: (a: ThingA) => ThingB,
reverseGet: (b: ThingB) => ThingA
)

In my experience, I’ve found that the main use of Iso is to convert between data structures to then do further processing. For example, sometimes it is easier to work with arrays instead of objects, in which case an object-to-array Iso helps.

const objectToArray = <A, B>() new Iso<Record<A,B>, Array<[A,B]>>(
obj => Object.entries(obj),
arr = arr.reduce((a, b) => ({ ...a, [b[0]]: b[1] }), {})
);

You have to be careful here that you know how your object behaves. For example, if we use the Iso above and then map all of the entries’ keys to the same entity of type A, we will lose all of the values but one of type B, which breaks the Iso promise. So, while an Iso is a helpful converter, make sure that it is a lossless converter or, alternatively, that you can accept whatever loss may come along with it!

Traversal

For example, monocle-ts has a fromTraversable function that allows you to easily build a Traversal from something implementing the Traversable interface in fp-ts. One common Traversable is array. So,

import { fromTraversable } from "monocle-ts";
import { array } from "fp-ts/lib/Array";
const arrayTraversal = fromTraversable(array)();
arrayTraversal.modify(i => i + 1)([1, 2, 3]); // [2, 3, 4]

Prism

const undefinedPrism= <A>() new Prism<A | undefined>(
myUnion => myUnion === undefined ? none : some(myUnion),
myA => myA
);

In general, the signature for a Prism takes two functions: one that does the discriminating, and one that passes in a valid value and yields the original value. The latter is usually the identity function. If it is not, consider composing your Prism with an Iso.

<A>(
get: (a: A) => Option<A>,
reverseGet: (a: A) => A
);

Summary

  • Lens = Accessor (ie a.b)
  • Iso = Map (ie f(a))
  • Traversal = Iterator (ie a.map)
  • Prism = Filter (ie a === b)

From these four objects, we can combine really powerful optics that dig deep into structures and modify them or yield their parts. For example, monocle-ts offers an Optional optic, which is a Lens + a Prism, that you’ll want to use whenever a field in an object may be undefined.

How to use optics

pleasework

Random musings about the things we do to make the things we…