We are delighted to announce the first Hackage release of optics, a Haskell library for defining and using lenses, traversals, prisms and other optic kinds. The optics library is broadly similar in functionality to the well-established lens library, but uses an abstract interface rather than exposing the underlying implementation of each optic kind. It aims to be easier to understand than lens, with clearer interfaces, simpler types and better error messages, while retaining as much functionality as possible. It is being developed by Andrzej Rybczak and Adam Gundry, with significant contributions from Oleg Grenrus and Andres Löh, and much copy-pasting of code and selective copying of ideas from lens.

Example of optics types and error messages

Let’s dive straight into an example of using optics in GHCi. What is a lens?

The Optic newtype unifies different optic kinds such as lenses, traversals and prisms. Its first type parameter, here A_Lens, indicates the optic kind in use. The second, NoIx, means that this is not an indexed optic (we will mostly ignore indexed optics for the purposes of this post). As in lens, the s and t parameters represent the types of the outer structure (before and after a type-changing update), and the a and b parameters represent the types of the inner field.

A lens can be constructed using, naturally enough, the lens function, which takes getter and setter functions and returns a Lens (i.e. an Optic A_Lens):

Given a lens we can use it to view the inner value within the outer structure, or set a new value:

Notice that these types are polymorphic in the optic kind k they accept, but specify very clearly what kind of optic they require.1 You can apply view to any optic kind k that can be converted to (i.e. is a subtype of) a Getter. The Is constraint implements subtyping using the typeclass system. In particular, we have instances for Is A_Lens A_Getter and Is A_Lens A_Setter so our lens l can be used with both operators:

If you try to use an optic kind that is not a subtype of the required type, a clear error message is given:

Composing optics

Optics are not functions, so they cannot be composed with the (.) operator. This may be viewed as a price to pay for the improved type inference and clearer type errors, but it is conceptually important: we regard optics as an abstract concept distinct from possible representations using functions, so it does not make sense to compose them with function composition or apply them with function application.2

Instead of (.), a separate composition operator (%) is provided:3

Composing optics of different kinds is fine, provided they have a common supertype, which the composition returns:

However, some optic kinds do not have a common supertype, in which case a type error results from trying to compose them:

The type of (%) itself is not entirely trivial. It relies on a type family Join to calculate the least upper bound of a pair of optic kinds:

However, you rarely work with (%) directly, and see only the results. The Join type family can be evaluated directly to determine how two optic kinds compose:

A little lens comparison

For comparison, let’s try the same sequence of commands with lens. Here the underlying implementation using the van Laarhoven representation is rapidly visible:

Using view and set is not much different:4

However, attempting to use a Setter where a Getter is expected does not report an error immediately, and when it does, the message is somewhat inscrutable:

Somewhat magically, lens uses the (.) function composition operator for optic composition:

Even more magically, this automatically selects the appropriate supertype when composing different optic kinds:

Once more, however, illegitimate compositions are not detected immediately but lead to a type with class constraints that can never be usefully satisfied:

Overloaded labels

Suppose we define two datatypes with the same field name:

Now we have a problem if we try to use name as a record selector or in a record update, because it is ambiguous which datatype is meant. The DuplicateRecordFields GHC extension can help with this to some extent, but it makes very limited use of type information to resolve the ambiguity. For example, name (Human "Peter" :: Human) will work but name (Human "Peter") is still considered ambiguous.

The GHC OverloadedLabels extension is intended to help in this situation, by providing a new syntax #name for an “overloaded label” whose interpretation is determined by its type. In particular, we can use overloaded labels as optics by giving instances of the LabelOptic class, with a few GHC extensions and a bit of boilerplate:5

Now we can use #name as a Lens, and the types will determine which field of which record is intended:

For more details on the support for overloaded labels in optics, check out the Haddocks for Optics.Label.

The hierarchy of optics

In optics, the hierarchy of optic kinds is closed, i.e. it is not possible to discover and make use of new optic kinds without modifying the library. Our aim is to make it easier to understand the interfaces and uses of different optic kinds, but this comes at the cost of obscuring some of the underlying common structure of the van Laarhoven or profunctor representations. One concrete limitation relative to lens is that we have not yet explored support for non-empty folds and traversals (Fold1 and Traversal1).

The diagram below shows the hierarchy of optic kinds supported by the initial release. Each arrow points from a subtype to its immediate supertype, e.g. every Lens can be used as a Getter:

Optics hierarchy
Optics hierarchy

The details of how indexed optics work are beyond the scope of this blog post (see the indexed optics Haddocks if you are interested), but the diagram below shows that every optic above Lens in the subtype hierarchy has an accompanying indexed variant:

Indexed optics
Indexed optics

Summary

What are the key ideas underpinning the optics library?

  • Every optic kind has a clear separation between interface and implementation, with a newtype abstraction boundary. This means the types reflect concepts such as lenses directly, rather than encoding them using higher-rank polymorphism. This leads to good type inference behaviour and (hopefully) clear error messages.

  • The interface of each optic kind is clearly and systematically documented. See the documentation for Optics.Lens as an example.

  • Since optics are not functions, they cannot be composed with the (.) operator. Instead a separate composition operator (%) is provided.

  • Subtyping between different optic kinds (e.g. using a lens as a traversal) is accomplished using typeclasses. This is mostly automatic, although explicit casts are possible and occasionally necessary.

  • Optics work with the OverloadedLabels GHC extension to allow the same name to be used for fields in different datatypes.

  • Under the hood, optics uses the indexed profunctor encoding (rather than the van Laarhoven encoding used by lens). This allows us to support affine optics (which have at most one target). We provide conversions between the optics and lens representations; for isomorphisms and prisms these are in a separate package optics-vl as this incurs a dependency on profunctors.

  • Indexed optics have a generally similar user experience to lens, but with different ergonomics (e.g. all optics are index-preserving, and there is no separate Conjoined class).

  • The main Optics module exposes only a restricted selection of operators, making inevitably opinionated choices about which operators are the most generally useful.

  • Sometimes functions in optics have a more specific type than the most general type possible, in the interests of simplicity and reducing the likelihood of errors. For example view does not work on folds, instead there is a separate function foldOf to eliminate folds, or gview if you really want additional polymorphism.

  • For library writers who wish to define optics as part of their library interface, we provide a cut-down optics-core package with significant functionality but minimal dependencies (only GHC boot libraries). Unlike lens, it is not possible to define lenses without depending on at least optics-core.

For a full introduction to optics, check out the Haddocks for the main Optics module. We welcome feedback and contributions on the GitHub well-typed/optics repo.

Acknowledgements

I would like to thank my coauthors Andrzej Rybczak, Oleg Grenrus and Andres Löh for all their work on optics. Edsko de Vries, Alp Mestanogullari, Ömer Sinan Ağacan and other colleagues at Well-Typed gave helpful feedback on the library in general and this blog post in particular. Thanks are also due to Edward Kmett for his work on lens and for critiquing (though not necessarily endorsing!) the ideas behind this library.


  1. They are also polymorphic in is, so they can be used with both indexed and unindexed optics.

  2. Neither do optics form a Category, because this would rule out optics with type-changing update or composition of optics of different kinds.

  3. An implementation detail leaks through here: the empty list '[] corresponds to NoIx and represents the empty list of indices, meaning that this optic is not indexed.

  4. lens generalises view over any MonadReader, and permits it to work on folds, whereas optics chooses not to by default. We provide a gview function in Optics.View that can be used similarly to view from lens.

  5. The boilerplate can be generated by Template Haskell now, and we are exploring making use of Generic instead. In the future we may be able to use a planned but not-yet-implemented addition to the GHC HasField class.