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?
*Optics> :info Lens
type Lens s t a b = Optic A_Lens NoIx s t a b
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
):
*Optics> :type lens
lens :: (s -> a) -> (s -> b -> t) -> Lens s t a b
*Optics> let l = lens (\(x,_) -> x) (\(_,y) x -> (x,y))
l :: Lens (a1, b) (a2, b) a1 a2
Given a lens we can use it to view
the inner value within the outer structure,
or set
a new value:
*Optics> :type view
view :: Is k A_Getter => Optic' k is s a -> s -> a
*Optics> :type set
set :: Is k A_Setter => Optic k is s t a b -> b -> s -> t
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:
*Optics> view l ('a','b')
'a'
*Optics> set l 'c' ('a','b')
'c','b') (
If you try to use an optic kind that is not a subtype of the required type, a clear error message is given:
*Optics> :type sets
sets :: ((a -> b) -> s -> t) -> Setter s t a b
*Optics> :type view (sets fmap)
<interactive>:1:1: error:
A_Setter cannot be used as A_Getter
• In the expression: view (sets fmap) •
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
*Optics> :type l % l
% l :: Optic A_Lens '[] ((a, b1), b2) ((b3, b1), b2) a b3
l *Optics> view (l % l) (('x','y'),'z')
'x'
Composing optics of different kinds is fine, provided they have a common supertype, which the composition returns:
*Optics> :type l % sets fmap
% sets fmap
l :: Functor f => Optic A_Setter '[] (f a, b1) (f b2, b1) a b2
However, some optic kinds do not have a common supertype, in which case a type error results from trying to compose them:
*Optics> :type to
to :: (s -> a) -> Getter s a
*Optics> :type to fst % sets fmap
<interactive>:1:1: error:
A_Getter cannot be composed with A_Setter
• In the expression: to fst % sets fmap •
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:
*Optics> :type (%)
%)
( :: (Is k (Join k l), Is l (Join k l)) =>
Optic k is s t u v
-> Optic l js u v a b -> Optic (Join k l) (Append is js) s t a b
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:
*Optics> :kind! Join A_Lens A_Setter
Join A_Lens A_Setter :: *
= A_Setter
*Optics> :kind! Join A_Getter A_Setter
Join A_Getter A_Setter :: *
= (TypeError ...)
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:
Control.Lens> :info Lens
type Lens s t a b =
forall (f :: * -> *). Functor f => (a -> f b) -> s -> f t
Control.Lens> :type lens
lens :: Functor f => (s -> a) -> (s -> b -> t) -> (a -> f b) -> s -> f t
Control.Lens> let l = lens (\(x,_) -> x) (\(_,y) x -> (x,y))
l :: Functor f => (a1 -> f a2) -> (a1, b) -> f (a2, b)
Using view
and set
is not much different:4
Control.Lens> :type view
view :: Control.Monad.Reader.Class.MonadReader s m =>
Getting a s a -> m a
Control.Lens> :type set
set :: ASetter s t a b -> b -> s -> t
Control.Lens> view l ('a','b')
'a'
Control.Lens> set l 'c' ('a','b')
'c','b') (
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:
Control.Lens> :type sets
sets :: (Profunctor p, Profunctor q, Settable f) =>
-> q s t) -> Optical p q f s t a b
(p a b Control.Lens> :type view (sets fmap)
fmap)
view (sets :: (Control.Monad.Reader.Class.MonadReader (f b) m,
Settable (Const b), Functor f) =>
m bControl.Lens> view (sets fmap) ('x','y')
<interactive>:82:7: error:
No instance for (Settable (Const Char))
• of ‘sets’
arising from a use ...
Somewhat magically, lens
uses the (.)
function composition operator for
optic composition:
Control.Lens> :type l . l
. l
l :: Functor f => (a1 -> f a2) -> ((a1, b1), b2) -> f ((a2, b1), b2)
Control.Lens> view (l . l) (('x','y'),'z')
'x'
Even more magically, this automatically selects the appropriate supertype when composing different optic kinds:
Control.Lens> :type l . sets fmap
. sets fmap
l :: (Settable f1, Functor f2) =>
-> f1 b1) -> (f2 a, b2) -> f1 (f2 b1, b2) (a
Once more, however, illegitimate compositions are not detected immediately but lead to a type with class constraints that can never be usefully satisfied:
Control.Lens> :type to
to :: (Profunctor p, Contravariant f) => (s -> a) -> Optic' p f s a
Control.Lens> :type to fst . sets fmap
fst . sets fmap
to :: (Contravariant f1, Settable f1, Functor f2) =>
-> f1 b1) -> (f2 b1, b2) -> f1 (f2 b1, b2) (b1
Overloaded labels
Suppose we define two datatypes with the same field name
:
data Human = Human { name :: String } deriving Show
data Pet = Pet { name :: String } deriving Show
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
{-# LANGUAGE OverloadedLabels DataKinds FlexibleInstances MultiParamTypeClasses
UndecidableInstances TypeFamilies #-}
instance (a ~ String, b ~ String) => LabelOptic "name" A_Lens Human Human a b where
= lens (\ (Human n) -> n) (\ _h n -> Human n )
labelOptic instance (a ~ String, b ~ String) => LabelOptic "name" A_Lens Pet Pet a b where
= lens (\ (Pet n) -> n) ( \ _p n -> Pet n ) labelOptic
Now we can use #name
as a Lens
, and the types will determine which field of
which record is intended:
*Optics> view #name (Human "Peter")
"Peter"
*Optics> set #name "Goldie" (Pet "Sparky")
Pet {name = "Goldie"}
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
:
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:
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 bylens
). This allows us to support affine optics (which have at most one target). We provide conversions between theoptics
andlens
representations; for isomorphisms and prisms these are in a separate packageoptics-vl
as this incurs a dependency onprofunctors
.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 separateConjoined
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 exampleview
does not work on folds, instead there is a separate functionfoldOf
to eliminate folds, orgview
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). Unlikelens
, it is not possible to define lenses without depending on at leastoptics-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.
They are also polymorphic in
is
, so they can be used with both indexed and unindexed optics.↩︎Neither do optics form a
Category
, because this would rule out optics with type-changing update or composition of optics of different kinds.↩︎An implementation detail leaks through here: the empty list
'[]
corresponds toNoIx
and represents the empty list of indices, meaning that this optic is not indexed.↩︎lens
generalisesview
over anyMonadReader
, and permits it to work on folds, whereasoptics
chooses not to by default. We provide agview
function inOptics.View
that can be used similarly toview
fromlens
.↩︎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 GHCHasField
class.↩︎