Nix Style Configs in Haskell

Subscribe via RSS

This is on GitHub

One of the unique things about Nix is its extensible configuration system. Extensible configs allow you to override their fields in a way that will update all the other fields that depend on what you override. In Nix, this is particularly useful for package management. It allows you to override a package (for example; change its version), and all the packages that depend on that package will subsequently use your overridden version.

We can represent this in Haskell, since it’s a lazy language. This is based on chains of functions that each take the previous one’s output as an argument, typically called super. By overriding a field in super, you get to change that field for every function that comes after you.

data MyConfig = MyConfig
  { _a :: Int
  , _b :: Int

makeLenses ''MyConfig

initial :: MyConfig
initial = MyConfig { _a = 1, _b = 2 }

overrideB :: MyConfig -> MyConfig
overrideB = b .~ 3

overrideA :: MyConfig -> MyConfig
overrideA super = super & a .~ (super ^. b)

final :: MyConfig
final = (overrideA . overrideB) initial -- final = MyConfig { _a = 3, _b = 3 }

This forms a pretty convenient monoid called Endo. This monoid is just the monoid of endomorphism composition. That is, it just composes functions with the same argument and return types.

import Data.Monoid (Monoid (..), (<>))

newtype Endo a = Endo { appEndo :: a -> a }

instance Monoid (Endo a) where
  mempty = Endo id
  Endo f `mappend` Endo g = Endo (f . g)


overrideB :: Endo MyConfig
overrideB = Endo (b .~ 3)

overrideA :: Endo MyConfig
overrideA = Endo $ \super -> super & a .~ (super ^. b)

final :: MyConfig
final = appEndo (overrideA <> overrideB) initial

So an extensible config is an Endo monoid that you can compose with mappend or (<>). But this only causes updates to the following overrides. Previous overrides will still see old records, which makes the config incoherent. You need to give the final version of the record (typically called self) to all overrides so they can use coherent records whenever possible. In a lazy language, this is easy; you can recursively give the final thunk to all overrides without having to evaluate those overrides first. An easy way to do this is to extend Endo to monadic endomorphisms, and use the Reader monad.

import Control.Monad ((<=<))
import Control.Monad.Reader
import Data.Functor.Identity
import Data.Function (fix)

newtype EndoM m a = EndoM { appEndoM :: a -> m a }

instance Monad m => Monoid (EndoM m a) where
  mempty = EndoM return
  EndoM f `mappend` EndoM g = EndoM (f <=< g)

type Endo = EndoM Identity

type Configurable a = EndoM (Reader a) a

configure :: Configurable a -> a
configure (EndoM f) = fix (\self -> runReaderT (f self) self)

-- | Lens convenience
overriding :: Setter' (EndoM m s) s
overriding = sets $ \f (EndoM g) -> EndoM (g . f)


-- This one serves as a bootstrap, allowing the constructor to
-- evaluate to WHNF. Whenever you don't have initial values for
-- fields, you can set the bootstrap to `EndoM $ \(~MyConfig {..}) ->
-- MyConfig {..}`, using lazy matching and `RecordWildCards` to
-- boostrap the thunk with nonterminating fields.
initial :: Configurable MyConfig
initial = EndoM $ \_ -> return MyConfig { _a = 1 , _b = 2 }

overrideA :: Configurable MyConfig
overrideA = EndoM $ \super -> do
  self <- ask
  return $ super & a .~ (self ^. b)

overrideB :: Configurable MyConfig
overrideB = mempty & overriding . b .~ 3

final :: MyConfig
final = configure (overrideB <> overrideA <> initial) -- MyConfig { _a = 3, _b = 3 }

Now, even though overrideA comes earlier than overrideB in the chain (remember, function composition, and therefore EndoM’s monoid instance, reads right to left), a still gets set to the value that’s defined for b later in the chain.

There’s also no reason that self and super have to have the same type. You can convert super into self at the end of the chain to get whatever finalization you need (even at the type level).

import Control.MonadFix (mfix)

type Configurable self super = EndoM (Reader self) super

configure :: (super -> self) -> Configurable self super -> self
configure f (EndoM g) = fix (f . runReader (mfix g))

And finally, this can of course work for any MonadFix, meaning your configuration steps can be monadic.

configureM :: MonadFix m => (super -> m self) -> EndoM (ReaderT self m) super -> m self
configureM f (EndoM g) = mfix (runReaderT (lift . f =<< mfix g))