Consider writing updates in a state monad where the state contains deeply nested structures. As our running example we will consider a state containing multiple “wallets”, where each wallet has multiple “accounts”, and each account has multiple “addresses”. Suppose we want to write an update that changes one of the fields in a particular address. If the address cannot be found, we want a precise error message that distinguishes between the address itself not being found, or one of its parents (the account, or the wallet) not being found. Without the help of suitable abstractions, we might end up writing something monstrous like:

setUsed :: AddrId -> Update UnknownAddr DB () () @ (accId @ (walletId, accIx), addrIx) = do setUsed addrId(accId(walletId, accIx), addrIx) <- get dbget -- find the wallet case Map.lookup walletId db of Map.lookup walletId db Nothing -> $ UnknownAddrParent throwError $ UnknownAccParent $ UnknownWalletId walletId walletId Just wallet -> wallet -- find the account case Map.lookup accIx wallet of Map.lookup accIx wallet Nothing -> $ UnknownAddrParent throwError $ UnknownAccId accId accId Just acc -> acc -- find the address case Map.lookup addrIx acc of Map.lookup addrIx acc Nothing -> $ UnknownAddrId addrId throwErroraddrId Just (addr, _isUsed) -> do (addr, _isUsed) let acc' = Map.insert addrIx (addr, True ) acc acc'Map.insert addrIx (addr,) acc = Map.insert accIx acc' wallet wallet'Map.insert accIx acc' wallet = Map.insert walletId wallet' db db'Map.insert walletId wallet' db put db'

In the remainder of this blog post we will show how we can develop some composable abstractions that will allow us to rewrite this as

setUsed :: AddrId -> Update UnknownAddr DB () () = setUsed addrId id addrId $ zoomAddressaddrId $ \(addr, _isUsed) -> (addr, True ) modify\(addr, _isUsed)(addr,

for an appropriate definition of zoomAddress given later.

Zooming

To obtain compositionality, we want to be able to lift updates on a smaller context (such as a particular wallet) to a larger context (the entire state). In order to do that, we will need a way to get the smaller context from the larger, and to be able to lift modifications of the smaller context to modifications of the larger context. This is of course precisely the definition of a lens, and so we arrive at the following signature:

zoom :: Lens' st st' -> State st' a -> State st a st st'st' ast a

For the purposes of the first part this blog post we will define State in a somewhat unusual way as

newtype Result a st = Result { getResult :: (a, st) } a st(a, st) } type State st a = st -> Result a st st asta st

It will become evident why we choose this definition soon; for now, if you squint a bit you can hopefully see that this is equivalent to the state monad we all know and love. A somewhat naive way to write zoom is

zoom :: Lens' st st' -> State st' a -> State st a st st'st' ast a = fmap updSmall $ f (large ^. l) zoom l f largeupdSmallf (largel) where = large & l .~ small' updSmall small'largesmall'

This definition clearly demonstrates what we said above: we use the lens to first get the small state from the large, run the update on that smaller state, and finally use the lens once more to update the larger state with the new value of the smaller state, relying on the fact that Result is a Functor .

If we are using lenses in Van Laarhoven representation, however, we can actually write this in a more direct way. Expanding synoynms, we get

zoom :: ( forall f . Functor f => (st' -> f st') (st'f st') -> (st -> f st)) (stf st)) -> (st' -> Result a st') (st'a st') -> (st -> Result a st) (sta st)

Note how if we take advantage of our somewhat unusual representation of the state monad, we can instantiate f to Result a , so that lens already gives us precisely what we need! In other words, we can rewrite zoom as simply

zoom :: Lens' st st' -> State st' a -> State st a st st'st' ast a = id zoom

Dealing with failure

In order to deal with missing values, we need a variation on zoom :

zoomM :: Lens' st ( Maybe st') -> State st' a -> State st ( Maybe a) st (st')st' ast (a)

We can write this in a naive way again, being very explicit about what’s happening:

zoomM :: Lens' st ( Maybe st') -> State st' a -> State st ( Maybe a) st (st')st' ast (a) = zoomM l f large case large ^. l of large Nothing -> Result ( Nothing , large) , large) Just small -> bimap Just (updSmall . Just ) $ f small smallbimap(updSmallf small where = large & l .~ small' updSmall small'largesmall'

As before, we first use the lens to get the smaller state from the larger. This may now fail; if it does, we return Nothing as the result along with the unchanged state. If the smaller state does exist, we run the update on that smaller state, and then wrap its result in Just ; this relies on the fact that Result is a Bifunctor . In case you haven’t seen that class before, it’s the “obvious” generalization of Functor to datatypes with two type arguments:

class Bifunctor p where bimap :: (a -> b) -> (c -> d) -> p a c -> p b d (ab)(cd)p a cp b d

The instance for Result is easy:

instance Bifunctor Result where Result (a, st)) = Result (f a, g st) bimap f g ((a, st))(f a, g st)

As before, however, we can use the lens in a more direct way. Expanding synonyms once again, we get:

zoomM :: ( forall f . Functor f => ( Maybe st' -> f ( Maybe st')) st'f (st')) -> (st -> f st)) (stf st)) -> (st' -> Result a st') (st'a st') -> (st -> Result ( Maybe a) st) (sta) st)

If we line up the result of the lens with the result we want from zoomM , we see that we must pick Result (Maybe a) for f ; all that remains is writing a suitable wrapper:

liftMaybe :: Biapplicative p => (st -> p a st) -> Maybe st -> p ( Maybe a) ( Maybe st) (stp a st)stp (a) (st) Nothing = bipure Nothing Nothing liftMaybe _bipure Just st) = bimap Just Just $ f st liftMaybe f (st)bimapf st

This relies on Result being Biapplicative , which is again the “obvious” generalization of Applicative to datatypes with two arguments:

class Bifunctor p => Biapplicative p where bipure :: a -> b -> p a b p a b (<<*>>) :: p (a -> b) (c -> d) -> p a c -> p b d p (ab) (cd)p a cp b d

The instance for Result again is straight-forward:

instance Biapplicative Result where = Result (a, st) bipure a st(a, st) Result (f, g) <<*>> Result (a, st) = Result (f a, g st) (f, g)(a, st)(f a, g st)

This out of the way, we can now define zoomM as

zoomM :: Lens' st ( Maybe st') -> State st' a -> State st ( Maybe a) st (st')st' ast (a) = l . liftMaybe zoomM lliftMaybe

Generalizing

So far we have been using a non-standard definition of the state monad. In this section we will see how we can avoid doing that and, more importantly, how we can write our zooming combinators in such a way that they can be used also in the reader monad.

Let’s define a monad for updates and a monad for queries using the standard monad transformers:

newtype Update e st a = Update { e st a runUpdate :: StateT st ( Except e) a st (e) a } deriving ( Functor , Applicative , Monad , MonadState st, MonadError e ) st,e ) newtype Query e st a = Query { e st a runQuery :: ReaderT st ( Except e) a st (e) a } deriving ( Functor , Applicative , Monad , MonadReader st, MonadError e ) st,e )

We want to be able to “zoom” in either of these two monads. We saw above that the key to be able to use the lens directly is the ability to express our update as a function

st -> f st

for some suitable functor f . For zoom we picked Result a , for zoomM we picked Result (Maybe a) . The choice of Result , however, was specific to our concrete definition of State . If we want to generalize, we need to generalize away from this type:

class Biapplicative ( Result z) => Zoomable z where z) type Result z :: * -> * -> * wrap :: (st -> Result z a st) -> z st a (stz a st)z st a unwrap :: z st a -> (st -> Result z a st) z st a(stz a st)

In this type class we introduce a type family Result that we can instantiate to different types for different monads; wrap and unwrap are necessary because unlike our bespoke State monad definition above, the conventional definition of the state monad is isomorphic, but not equal, to a function from a state to a state. We saw above why we need Result z to be Biapplicative .

In order to be able to define a Zoomable instance for Update , we need to introduce a type that captures the result of an update:

newtype UpdResult e a st = UpdResult { e a st getUpdResult :: Except e (a, st) e (a, st) }

Defining the Zoomable instance for UpdResult is now easy:

instance Zoomable ( Update e) where e) type Result ( Update e) = UpdResult e e) = coerce wrapcoerce = coerce unwrapcoerce

Note that wrap and unwrap are simply coerce ; in other words, they exist only to satisfy the type checker, but have no runtime cost.

Zoomable instances for Query

The nice thing is that we can just as easily give a Zoomable instance for Query . The only difference is that the result of the query does not have a final state:

newtype QryResult e a st = QryResult { e a st getQryResult :: Except e a e a }

The Zoomable instance is just as simple:

instance Zoomable ( Query e) where e) type Result ( Query e) = QryResult e e) = coerce wrapcoerce = coerce unwrapcoerce

Functor from Bifunctor

If we now try to define zoom for any Zoomable monad, we find that we get stuck very quickly: in order to be able to apply the lens, we need Result z a to be a functor; but all we know is that Result z is a bifunctor. Starting from ghc 8.6 we could use quantified constraints and write

class ( Biapplicative ( Result z) z) , forall a . Functor ( Result z a) z a) ) => Zoomable z where ( .. )

to insist that Result z a must be a functor for any choice of a . We could also add a Functor (Result z a) constraint to the type of zoom itself, but this gives zoom a more messy signature than it needs to have.

If we want to be compatible with older versions of ghc but still keep the nicer signature, we can take advantage of the fact that if a datatype is a bifunctor it must also be a functor:

newtype FromBi p a st = WrapBi { unwrapBi :: p a st } p a stp a st } instance Bifunctor p => Functor ( FromBi p a) where p a) fmap f ( WrapBi x) = WrapBi (second f x) f (x)(second f x)

Generalizing the zoom operators

We now have everything we need to give the generalized definitions of the zoom operators. In fact, the definition is almost dictated by the types:

zoom :: Zoomable z => Lens' st st' -> z st' a -> z st a st st'z st' az st a = wrap $ \st -> unwrapBi $ l ( WrapBi . unwrap k) st zoom l kwrap\stunwrapBil (unwrap k) st

Although this looks more complicated than the definition we have before, note that

zoom l k -- definition == wrap $ \st -> unwrapBi $ l ( WrapBi . unwrap k) st wrap\stunwrapBil (unwrap k) st -- wrap and unwrap are both 'coerce' == \st -> unwrapBi $ l ( WrapBi . k) st \stunwrapBil (k) st -- unwrapBi and WrapBi are just newtype wrappers == \st -> l k st \stl k st -- eta-reduce == l k l k

In other words, modulo newtype wrapping, we still have zoom = id . The definition of zoomM is similar to what we had above also:

zoomM :: Zoomable z => Lens' st ( Maybe st') st (st') -> z st' a z st' a -> z st ( Maybe a) z st (a) = wrap $ \st -> unwrapBi $ zoomM l kwrap\stunwrapBi WrapBi . liftMaybe (unwrap k)) st l (liftMaybe (unwrap k)) st

The proof that this is equivalent to simply l (liftMaybe k) is left as a simple exercise for the reader.

Finally, we can define a useful variation on zoomM that uses a fallback when the smaller context was not found:

zoomDef :: ( Zoomable z, Monad (z st)) z,(z st)) => Lens' st ( Maybe st') st (st') -> z st a -- ^ When not found z st a -> z st' a -- ^ When found z st' a -> z st a z st a = zoomMaybe l k `catchNothing` def zoomDef l def kzoomMaybe l kdef

where

catchNothing :: Monad m => m ( Maybe a) -> m a -> m a m (a)m am a = act >>= maybe fallback return catchNothing act fallbackactfallback

Using the combinators

We will now go back to the example from the introduction and show how we can write some domain-specific zoom operators using the building blocks we just defined.

Setup

The example is a state consisting of multiple wallets, where each wallet has multiple accounts, and each account has multiple addresses. For the sake of this blog post it doesn’t really matter what “wallets”, “accounts” and “addresses” are, and we will model them very simply as

type DB = Map WalletId Wallet type Wallet = Map AccIx Account type Account = Map AddrIx Address type Address = ( String , Bool )

The top-level state is a mapping from wallet IDs to wallets, but a wallet is a mapping from account indices to accounts. The reason for the difference is that we will reserve the term account ID for the combination of a wallet ID and an account index, and similarly for addresses:

type AccIx = Int type AddrIx = Int type WalletId = Int type AccId = ( WalletId , AccIx ) type AddrId = ( AccId , AddrIx )

Finally, the requirements stated that we wanted to distinguish between, say, an address not found because although the account exists, it doesn’t have that particular address, and an address not found because its enclosing account (or indeed wallet) does not exist:

data UnknownWallet = UnknownWalletId WalletId data UnknownAcc = UnknownAccId AccId | UnknownAccParent UnknownWallet data UnknownAddr = UnknownAddrId AddrId | UnknownAddrParent UnknownAcc

Zooming

Ok, definitions done, we can now define our zoom combinators. Our initial attempt might be something like

zoomWallet :: WalletId -> Update e Wallet a -> Update e DB a

If the wallet ID was not found, however, we want to be able to throw an UnknownWallet error. We could change the signature to

zoomWallet :: WalletId -> Update UnknownWallet Wallet a -> Update UnknownWallet DB a

but now we cannot use zoomWallet for updates with a richer error type. A better solution is to take as an argument a function that allows us to embed the UnknownWallet error into e :

zoomWallet :: ( UnknownWallet -> e) e) -> WalletId -> Update e Wallet a -> Update e DB a = zoomWallet embedErr walletId k zoomDef (at walletId) $ embedErr ( UnknownWalletId walletId)) $ (throwErrorembedErr (walletId)) k

The definition is pleasantly straightforward. We use the at combinator from lens to give us a lens into the map, and then use zoomDef with a fallback that throws the error to complete the definition.

Composition

In order to show that our new combinators are compositional we should be able to define zoomAccount in terms of zoomWallet , and indeed we can:

zoomAccount :: ( UnknownAcc -> e) e) -> AccId -> Update e Account a -> Update e DB a @ (walletId, accIx) k = zoomAccount embedErr accId(walletId, accIx) k . UnknownAccParent ) walletId $ zoomWallet (embedErr) walletId zoomDef (at accIx) $ embedErr ( UnknownAccId accId)) $ (throwErrorembedErr (accId)) k

Composing the zoom combinators is effectively lens composition, which is taking care of getting the account from the DB by first getting the account in one direction, and updating the DB by first lifting the update on the account to an update on the wallet, and then to an update on the DB itself.

The “embed error” argument is helping with compositionality also: zoomAccount needs its embedErr to embed UnknownAcc into e , but when it calls zoomWallet it composes embedErr with UnknownAccParent to embed UnknownWallet into e .

The definition for address follows the exact same pattern:

zoomAddress :: ( UnknownAddr -> e) e) -> AddrId -> Update e Address a -> Update e DB a @ (accId, addrIx) k = zoomAddress embedErr addrId(accId, addrIx) k . UnknownAddrParent ) accId $ zoomAccount (embedErr) accId zoomDef (at addrIx) $ embedErr ( UnknownAddrId addrId)) $ (throwErrorembedErr (addrId)) k

so that we can now write the definition we promised in the introduction:

setUnused :: AddrId -> Update UnknownAddr DB () () = setUnused addrId id addrId $ zoomAddressaddrId $ \(addr, _isUsed) -> (addr, False ) modify\(addr, _isUsed)(addr,

Iteration

There is one additional zoom operator that is very useful to define. Suppose we want to clear out all wallets. If we tried to write this with the combinators we have so far, we would end up with something like

emptyAllWallets :: Update UnknownWallet DB () () = do emptyAllWallets <- gets Map.keys walletIdsgets Map.keys $ \walletId -> forM_ walletIds\walletId id walletId $ zoomWalletwalletId put Map.empty

We get all wallet IDs, then zoom to each wallet in turn and empty it. However, notice the signature: it indicates that emptyAllWallets may throw a UnknownWallet error—but it never will! After all, we just read all wallet IDs, so we know for a fact that they must be present. One “solution” is to do something like

emptyAllWallets :: Update e DB () () = do emptyAllWallets <- gets Map.keys walletIdsgets Map.keys $ \walletId -> forM_ walletIds\walletId -> error "can't happen" ) walletId $ zoomWallet (\_err) walletId put Map.empty

but we can do much better: we need a zoom operator that gives us iteration.

Traversals

In the world of lens , iteration is captured by a Traversal' . Compare the synoynms:

type Lens' st st' = forall f . Functor f => (st' -> f st') st st'(st'f st') -> (st -> f st) (stf st) type Traversal' st st' = forall f . Applicative f => (st' -> f st') st st'(st'f st') -> (st -> f st) (stf st)

A traversal will apply its argument to all occurrences of the smaller state; in order to patch the results back together it needs f to be Applicative rather than merely a Functor .

Applicative from Biapplicative

Remember that the f we’re using in Zoomable is the Result z type family, which we know to be Biapplicative . We showed above that we can easily derive Functor from Bifunctor ; deriving Applicative from Biapplicative , however, is not so easy! Let’s see what we need to do:

instance Biapplicative p => Applicative ( FromBi p a) where p a) pure st = WrapBi $ bipure _e st stbipure _e st <*> arg = WrapBi $ bimap _c ( $ ) (unwrapBi fun) funargbimap _c () (unwrapBi fun) <<*>> unwrapBi arg unwrapBi arg

There are two problematic holes in this definition:

For pure we need to construct an p a st from just a state st ; we need to construct an _e :: a out of thin air. This corresponds to having no results at all.

we need to construct an from just a state ; we need to construct an out of thin air. This corresponds to having no results at all. For (<*>) we need a function _c :: a -> a -> a that combines two results into a single one.

The usual solution to this problem is to require a to be a monoid. Then we can use mempty for the absence of a result, and mappend to combine results:

instance ( Biapplicative p , Monoid a ) => Applicative ( FromBi p a) where p a) pure st = WrapBi $ bipure mempty st stbipurest <*> arg = WrapBi $ bimap mappend ( $ ) (unwrapBi fun) funargbimap) (unwrapBi fun) <<*>> unwrapBi arg unwrapBi arg

Zooming

We can now define zoomAllM :

zoomAllM :: ( Zoomable z, Monoid a) z,a) => Traversal' st st' -> z st' a -> z st a st st'z st' az st a = wrap $ \st -> unwrapBi $ l ( WrapBi . unwrap k) st zoomAllM l kwrap\stunwrapBil (unwrap k) st

Apart from the signature, the body of this function is literally identical to zoom , and is therefore also equivalent to simply id . Mind-blowing stuff.

We can define two useful wrappers for zoomAllM with slightly simpler types. The first is just a synoynm which can be used when we don’t want to accumulate any results:

zoomAll_ :: Zoomable z => Traversal' st st' -> z st' () -> z st () st st'z st' ()z st () = zoomAllM zoomAll_zoomAllM

This works because () is trivially a monoid. Finally we can define a wrapper that accumulates results in a list:

zoomAll :: Zoomable z => Traversal' st st' -> z st' a -> z st [a] st st'z st' az st [a] = wrap $ \st -> unwrapBi $ zoomAll l kwrap\stunwrapBi WrapBi . first ( : []) . unwrap k) st l (first ([])unwrap k) st

We could have defined zoomAll in terms of zoomAllM if we insist that z st' is a Functor ; by unfolding the definition we can take advantage of the fact that Result z is a bifunctor and we keep the signature clean.

Usage example

The example function we were considering was one that cleared out all wallets. With our new combinators in hand, this is now trivial:

emptyAllWallets :: Update e DB () () = zoomAll_ traverse $ put Map.empty emptyAllWalletszoomAll_put Map.empty

Conclusions

As Haskell programmers, compositionality is one of our most treasured principles. The ability to build larger components from smaller, and reason about larger components by reasoning about the smaller, is crucial to productivity and clean, maintainable code. When dealing with large states (for example, in an acid-state database), lenses are a powerful tool that can be used to lift operations on parts of the state to the whole state. In this blog post we defined some reuseable combinators that can be used both in updates and in queries; they are used extensively in the design of the new Cardano wallet kernel.

Postscript: zoom from Control.Lens.Zoom