Skip to content

Tutorial#

One of the following imports is assumed in all the examples.

Method chaining:

import * as O from 'optics-ts'

Standalone:

import * as O from 'optics-ts/standalone'

See The Two Syntaxes for the difference between the two, and which one to choose. All the descriptions and examples below are given in terms of both syntaxes.

Lens#

Lens is the most common optic you're going to use. You can create an optic for a data structure by calling optic(), and turn in into a lens that focuses on a property of an object with prop:

type Data = {
  foo: { bar: number }
  other: string
}
const foo = O.optic<Data>().prop('foo')

foo is now a lens that focuses on Data.foo.

To dig deeper, just call prop again:

const bar = O.optic<Data>().prop('foo').prop('bar')
// or from the `foo` lens we defined above
const bar = foo.prop('bar')
// or use .path() to compose multiple prop lenses with a single call
const bar = O.optic<Data>().path('foo', 'bar')
// or use path with a dotted string path
const bar = O.optic<Data>().path('foo.bar')

Use get to read a value through the lens:

const data: Data = {
  foo: { bar: 42 },
  other: 'stuff',
}

O.get(bar)(data)
// 42

Use set or modify to write the focused value through the lens:

O.set(bar)(99)(data)
// {
//   foo: { bar: 99 },
//   other: 'stuff'
// }

O.modify(bar, (x) => x * 100, data)
// {
//   foo: { bar: 4200 },
//   other: 'stuff'
// }

Lens is the most common optic you're going to use. You can create a lens that focuses on a property of an object with prop:

const foo = O.prop('foo')

foo is now a lens that focuses on the prop foo of any given object.

To dig deeper, compose multiple prop lenses:

const bar = O.compose(O.prop('foo'), O.prop('bar'))
// or reusing the `foo` lens we defined above
const bar = O.compose(foo, O.prop('bar'))

Because prop is such an often used lens, you can pass string arguments directly to compose, and they will be taken as the prop lens:

const bar = O.compose('foo', 'bar')

Use get to read a value through the lens:

const data = {
  foo: { bar: 42 },
  other: 'stuff',
}

O.get(bar, data)
// 42

Use set or modify to write the focused value through the lens:

O.set(bar, 99, data)
// {
//   foo: { bar: 99 },
//   other: 'stuff'
// }

O.modify(bar, (x) => x * 100, data)
// {
//   foo: { bar: 4200 },
//   other: 'stuff'
// }

Writing through optics always creates a new data structure instead of modifying the existing one in place, shallowly copying the required parts. In other words, data is immutable.

Prism#

Lenses are great for focusing to a part of a larger structure. Prisms are much like lenses, but they don't necessarily match anything, i.e. they can have zero focuses.

A practical example is focusing on a branch of a union type. Here, the User.age field can be number or undefined. With the optional prism we can focus only when the value is a number, and do nothing when it's undefined:

type User = {
  name: string
  age?: number | undefined
}

const age = O.optic<User>().prop('age').optional()
type User = {
  name: string
  age?: number | undefined
}

const age = O.compose('age', O.optional)

You read through a prism using the preview function. When the prism doesn't match, it returns undefined.

const userWithAge: User = {
  name: 'Betty',
  age: 42,
}
O.preview(age)(userWithAge)
// 42

const userWithoutAge: User = {
  name: 'Max',
  age: undefined,
}
O.preview(age)(userWithoutAge)
// undefined
const userWithAge: User = {
  name: 'Betty',
  age: 42,
}
O.preview(age, userWithAge)
// 42

const userWithoutAge: User = {
  name: 'Max',
  age: undefined,
}
O.preview(age, userWithoutAge)
// undefined

You can write through a prism normally with set and modify. If the prism doesn't match, the value is unchanged:

O.modify(age)((n) => n + 1)(userWithAge)
// {
//   name: 'Betty',
//   age: 43,
// }

O.set(age)(60)(userWithoutAge)
// {
//   name: 'Max',
//   age: undefined,
// }
O.modify(age, (n) => n + 1, userWithAge)
// {
//   name: 'Betty',
//   age: 43,
// }

O.set(age, 60, userWithoutAge)
// {
//   name: 'Max',
//   age: undefined,
// }

guard is another way to create a prism. It's a generalization of optional in the sense that you can match on any branch of a union type instead of just the non-undefined part:

interface Circle {
  kind: 'circle'
  radius: number
}
interface Rectangle {
  kind: 'rectangle'
  width: number
  height: number
}
type Shape = Circle | Rectangle

function isRectangle(s: Shape): s is Rectangle {
  return s.kind === 'rectangle'
}
const rectWidth = O.optic<Shape>().guard(isRectangle).prop('width')

O.preview(rectWidth)({ kind: 'circle', radius: 10 })
// undefined

O.preview(rectWidth)({ kind: 'rectangle', width: 5, height: 7 })
// 5

O.modify(rectWidth)((w) => w * 2)({ kind: 'rectangle', width: 5, height: 7 })
// { kind: 'rectangle', width: 10, height: 7 })
const rectWidth = O.compose(O.guard(isRectangle), 'width')

O.preview(rectWidth, { kind: 'circle', radius: 10 })
// undefined

O.preview(rectWidth, { kind: 'rectangle', width: 5, height: 7 })
// 5

O.modify(rectWidth, (w) => w * 2, { kind: 'rectangle', width: 5, height: 7 })
// { kind: 'rectangle', width: 10, height: 7 })

Notice how above we composed the guard prism with the prop lens. This yields a prism, so we used preview to read through it. See Rules of composition for more info.

Removable optics#

Some optics are removable. This means that they focus on an element of a container (e.g. an array), and you can remove the element from the container.

at is a removable prism. It focuses on an index of an array, and lets you also remove that index:

interface User {
  name: string
}

const threeUsers: User[] = [
  { name: 'Max' },
  { name: 'Betty' },
  { name: 'Alice' },
]

const secondUser = O.optic<User[]>().at(1)
O.remove(secondUser)(threeUsers)
// [{ name: 'Max' }, { name: 'Alice' }]
interface User {
  name: string
}

const threeUsers: User[] = [
  { name: 'Max' },
  { name: 'Betty' },
  { name: 'Alice' },
]

O.remove(O.at(1), threeUsers)
// [{ name: 'Max' }, { name: 'Alice' }]

If the optic doesn't match, removing has no effect:

const oneUser: User[] = [{ name: 'Max' }]

O.remove(secondUser)(oneUser)
// [{ name: 'Max' }]
const oneUser: User[] = [{ name: 'Max' }]

O.remove(O.at(1), oneUser)
// [{ name: 'Max' }]

Traversal#

The next optic type is the traversal. While lenses have one focus and prisms have zero or one focuses (no match or match), traversals have zero or more focuses.

The simplest example of a traversal is to focus on all elements of an array. To create such a traversal, use elems:

type Person {
  name: string
  friends: Person[]
}

const friendsNames = O.optic<Person>()
  .prop('friends')
  .elems()
  .prop('name')
type Person {
  name: string
  friends: Person[]
}

const friendsNames = O.compose('friends', O.elems, 'name')

To read through a traversal, call collect to collect all focused elements into an array:

const john = { name: 'John', friends: [] }
const bruce = { name: 'Bruce', friends: [] }
const amy = { name: 'Amy', friends: [john, bruce] }

O.collect(friendsNames)(amy)
// [ 'John', 'Bruce' ]
const john = { name: 'John', friends: [] }
const bruce = { name: 'Bruce', friends: [] }
const amy = { name: 'Amy', friends: [john, bruce] }

O.collect(friendsNames, amy)
// [ 'John', 'Bruce' ]

Writing through a traversal writes to all focused values:

O.modify(friendsNames)((name) => `${name} Wayne`)(amy)
// {
//   name: 'Amy',
//   friends: [
//     { name: 'John Wayne', friends: [] },
//     { name: 'Bruce Wayne', friends: [] },
//   ],
// }
O.modify(friendsNames, (name) => `${name} Wayne`, amy)
// {
//   name: 'Amy',
//   friends: [
//     { name: 'John Wayne', friends: [] },
//     { name: 'Bruce Wayne', friends: [] },
//   ],
// }

Note again how we used prop, elems and prop, composing a lens with a traversal, and then with a lens again. This yields a traversal. See Rules of composition for more info.

It's sometimes useful to further focus on certain elements of a traversal. This can be done by composing a traversal with a prism like when that skips items that don't match a predicate:

const even = O.optic<number[]>()
  .elems()
  .when((n) => n % 2 === 0)

O.modify(even)((n) => -n)([1, 2, 3, 4, 5])
// [1, -2, 3, -4, 5]
const even = O.compose(
  O.elems,
  O.when((n: number) => n % 2 === 0)
)

O.modify(even, (n) => -n, [1, 2, 3, 4, 5])
// [1, -2, 3, -4, 5]

Polymorphism#

Optics can be polymorphic, which means you can change the type of the focus when you write through an optic. Since this is a relatively rare use case, and may be confusing if done by accident, polymorphic optics are created with optic_ (note the underscore):

type Data = {
  foo: { bar: string }
  other: boolean
}
const bar = O.optic_<Data>().path('foo.bar')

Optics can be polymorphic, which means you can change the type of the focus when you write through an optic.

type Data = {
  foo: { bar: string }
  other: boolean
}
const bar = O.compose('foo', 'bar')

Let's modify bar to contain the length of the original string instead:

const data: Data = {
  foo: { bar: 'hello there' },
  other: true,
}

const updated = O.modify(bar)((str) => str.length)(data)
// {
//   foo: { bar: 11 },
//   other: true
// }

This is a type-safe operation, i.e. the compiler knows that the type of updated.foo.bar is number, editor autocomplete works correctly, etc.

If you ever see a DisallowedTypeChange type being returned from an optics-ts function, it means that you tried to change the type when writing through a non-polymorphic (monomorphic) optic.

const data: Data = {
  foo: { bar: 'hello there' },
  other: true,
}

const updated = O.modify(bar, (str) => str.length, data)
// {
//   foo: { bar: 11 },
//   other: true
// }

This is a type-safe operation, i.e. the compiler knows that the type of updated.foo.bar is number, editor autocomplete works correctly, etc.