# Backwards Compatible Configuration with Constraint Kinds

Programs must often handle data whose specification or schema evolves over time. A changing schema presents challenges at large scales in the form of database migrations or ETL, and anyone who has worked in data science has an idea of how much time this can consume. Schema changes have impacts on smaller scales as well, e.g. an application’s configuration file.

Consider an application configuration like the following. Dhall is a great solution for configuration:

```
data Config1 = Config1 {
conf1FilePath :: FilePath
, conf1UserName :: Text
, conf1Workers :: Natural
} deriving (Show, Generic, Interpret)
parseConfig1 :: Text -> IO (Maybe Config1)
parseConfig1 t = extract auto <$> inputExpr t
```

Here `Interpret`

is a class from the Dhall package that we may derive given `Generic`

. It provides us a way to marshal a Dhall expression into a `Config1`

; check out Dhall’s excellent tutorial for more details. For now, all that matters is that we’ve got a function that will attempt to parse our configuration given the text of our configuration file.

Later on the configuration schema might need to be modified. Suppose the `conf1UserName`

field is now optional, and we need a host name as well:

```
data Config2 = Config2 {
conf2FilePath :: FilePath
, conf2UserName :: Maybe Text
, conf2Workers :: Natural
, conf2HostName :: Text
} deriving (Show, Generic, Interpret)
```

Users will already have configuration files that obey the `Config1`

schema, but they clearly won’t obey the `Config2`

schema. It’d be nice to make use of the user’s existing configuration file by migrating it to the new schema if possible. Better still would be offering to write the migrated configuration back to disk. There are several syntax-oriented ways of dealing with this problem. If we were using JSON, YAML, or even an IDL like Google’s ProtocolBuffers, we could follow syntactic prescriptions such as “make all fields optional” and get backwards compatibility “for free.” However, there are numerous consequences to this approach.

Firstly, the syntactic prescriptions (which, in the case of ProtocolBuffers, are numerous) require the semantics of the schema to be arbitrarily obscured. If we follow the “all fields are optional” rule, there are likely to be fields in the schema that are syntactically optional but practically required. Handling the absence of these specified-optional-but-actually-required fields is kicked down the road. Secondly, the changes to the semantics between schemas won’t be clear from the difference in syntax alone. Again picking on the “all fields are optional rule,” if this is obeyed, a field’s transition from required to optional or vice versa doesn’t show up in the syntax at all!

Dhall itself presents a syntax-oriented way of handling such schema changes: the combine operator `(//)`

may be used to combine two records, with a bias towards the right-hand record when fields collide. This would allow the use of `default // userConfig`

, where `default`

is some default configuration value obeying the most recent schema, and `userConfig`

contains only updates to this default value. This is robust to some schema changes (e.g. adding new fields) but not others.

The most natural thing to do would be to state the changes to the schema semantics explicitly. Consider a class for this:

The `Pred`

associated type indicates a schema we can migrate from. If we build a linear chain of `Compat`

instances bridging each schema version, we can recursively `migrate`

our way up to the latest schema version. Here’s an instance bridging `Config2`

back to `Config1`

:

```
instance Compat Config2 where
type Pred Config2 = Config1
migrate c1 = Config2 {
conf2FilePath = conf1FilePath c1
, conf2UserName = Just (conf1UserName c1)
, conf2Workers = conf1Workers c1
, conf2HostName = "http://example.com"
}
```

`Compat`

allows us to write down exactly the change in semantics from one schema version to another. Some readers may be keen to point out that there exist all manner of record-oriented language extensions and lens functions that could be employed to shorten the definition of `migrate`

. That’s true, and they’d be useful in practice, but for clarity’s sake they will not be used here. Now we can write a `parseConfig2`

that can handle encountering a configuration file meant for `Config1`

:

```
parseConfig2 :: Text -> IO (Maybe Config2)
parseConfig2 t = do
dhallExpr <- inputExpr t
let maybeConfig1 = extract auto dhallExpr :: Maybe Config1
maybeConfig2 = extract auto dhallExpr :: Maybe Config2
pure $ case maybeConfig2 of
Nothing -> migrate <$> maybeConfig1
config2 -> config2
```

This simple definition of `Compat`

illustrates this idea nicely, but has a few practical problems. Consider a future in which we’ve made it all the way to configuration schema version `ConfigN`

. Our definition of `parseConfigN`

would be something like:

```
parseConfigN :: Text -> IO (Maybe ConfigN)
parseConfigN t = do
dhallExpr <- inputExpr t
let maybeConfig1 = extract auto dhallExpr :: Maybe Config1
maybeConfig2 = extract auto dhallExpr :: Maybe Config2
....
maybeConfigN = extract auto dhallExpr :: Maybe ConfigN
pure $ case maybeConfigN of
Nothing -> case ...
...
configN -> configN
```

Ideally we wouldn’t need to change the definition of our `parseConfig`

function when the schema evolves. A new type for the new schema and a `Compat`

instance should be all that’s required. Additionally, it’d be handy to have a less restrictive type for the `migrate`

method. For some schemas, the migration might be partial and need to fail with an error, or the result might be paired with a warning message for the user. Let’s tackle this second point first, and generalize `migrate`

:

The `CompatF`

associated type allows an instance to say which type the migration result will be wrapped up in. Recall that the kind `*`

includes all inhabited types; things like `Int`

, `ByteString`

, and saturated type constructors like `Maybe Int`

, `[Int]`

, and `Either String Int`

. The kind `* -> *`

includes unsaturated type constructors with one free argument, like `Maybe`

, `(->) Int`

, `Either String`

, and `IO`

. Here’s our new instance:

```
instance Compat Config2 where
type Pred Config2 = Config1
type CompatF Config2 = Maybe
migrate c1 = Just $ Config2 {
conf2FilePath = conf1FilePath c1
, conf2UserName = Just (conf1UserName c1)
, conf2Workers = conf1Workers c1
, conf2HostName = "http://example.com"
}
```

Let’s use `Maybe`

for `CompatF Config2`

to allow `migrate`

to signal failure by returning `Nothing`

. Now that `migrate`

returns a `Maybe Config2`

, we can fix up our previous definition of `parseConfig2`

by swapping out `migrate <$> maybeConfig1`

for `maybeConfig1 >>= migrate`

:

```
parseConfig2 :: Text -> IO (Maybe Config2)
parseConfig2 t = do
dhallExpr <- inputExpr t
let maybeConfig1 = extract auto dhallExpr :: Maybe Config1
maybeConfig2 = extract auto dhallExpr :: Maybe Config2
pure $ case maybeConfig2 of
Nothing -> maybeConfig1 >>= migrate
config2 -> config2
```

Let’s further extend the schema. Suppose that we’ve changed our minds about the `conf2HostName`

field. `"http://example.com"`

is now the only choice users have, so we’ll remove the field and fail to migrate any user configurations that used a hostname other than `"http://example.com"`

:

```
data Config3 = Config3 {
conf3FilePath :: FilePath
, conf3UserName :: Maybe Text
, conf3Workers :: Natural
} deriving (Show, Generic, Interpret)
instance Compat Config3 where
type Pred Config3 = Config2
type CompatF Config3 = Maybe
migrate c2
| conf2HostName c2 == "http://example.com" =
Just $ Config3 {
conf3FilePath = conf2FilePath c2
, conf3UserName = conf2UserName c2
, conf3Workers = conf2Workers c2
}
| otherwise = Nothing
```

Here’s `parseConfig3`

, but it’s beginning to become repetitive:

```
parseConfig3 :: Text -> IO (Maybe Config3)
parseConfig3 t = do
dhallExpr <- inputExpr t
let maybeConfig1 = extract auto dhallExpr :: Maybe Config1
maybeConfig2 = extract auto dhallExpr :: Maybe Config2
maybeConfig3 = extract auto dhallExpr :: Maybe Config3
pure ( maybeConfig3
<|> (maybeConfig2 >>= migrate)
<|> (maybeConfig1 >>= migrate >>= migrate)
)
```

The handy `<|>`

operator from the `Alternative`

class helps a bit, but it’d be nicer if this function was generic. How about something like this:

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
dhallExpr <- inputExpr t
let migrateConfig expr =
extract auto expr <|> (migrateConfig expr >>= migrate)
pure (migrateConfig dhallExpr)
```

The type has changed, but the definition has similar structure: the `Monad`

instance for `Maybe`

is used to pass a `Maybe (Pred a)`

through to `Migrate`

, and the `Alternative`

instance for `Maybe`

is used to stop recursion once we successfully get a configuration value from `extract auto expr`

. This new type signature warrants some explanation.

First, the `Interpret`

constraint comes from Dhall. This is what gives us access to `extract auto expr :: Interpret a => Maybe a`

. The `Compat`

constraint gives us `Pred a`

, `CompatF a`

, and `migrate`

. The `Maybe ~ (CompatF a)`

bit is called an equality constraint. `Maybe`

provides the `Alternative`

and `Monad`

instances we want to use with `extract auto expr`

, so this constraint checks that the `Compat`

instance for the type we’re parsing uses `Maybe`

for `CompatF`

. The only problem with this definition is that it doesn’t typecheck. Specifically, GHC 8.6.4 says:

```
• Could not deduce: Pred a ~ a
from the context: (Interpret a, Compat a, Maybe ~ CompatF a)
bound by the type signature for:
parseCompatConfig :: forall a.
(Interpret a, Compat a, Maybe ~ CompatF a) =>
Text -> IO (CompatF a a)
```

Indeed, `Pred a ~ a`

isn’t what we want at all. The inferred type of `migrateConfig`

is the likely culprit. Using typed holes we can query GHC for the inferred type. Feeding it this:

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
dhallExpr <- inputExpr t
let migrateConfig :: _
migrateConfig expr =
extract auto expr <|> (migrateConfig expr >>= migrate)
pure (migrateConfig dhallExpr)
```

Yields the inferred type:

```
• Found type wildcard ‘_’ standing for ‘Expr Src X -> Maybe a’
Where: ‘a’ is a rigid type variable bound by
the type signature for:
parseCompatConfig :: forall a.
(Interpret a, Compat a, Maybe ~ CompatF a) =>
Text -> IO (CompatF a a)
```

The issue is that we can only get back a `Maybe a`

from `migrateConfig`

, where `a`

is the type of the config we’re returning from `parseCompatConfig`

. However, the recursive call to `migrateConfig`

needs to return a `Maybe (Pred a)`

, so GHC figures that `a ~ Pred a`

must be true. What we really need is something like this:

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
dhallExpr <- inputExpr t
let migrateConfig :: ( Interpret b
, Compat b
, Maybe ~ CompatF b
)
=> Expr Src X -> Maybe b
migrateConfig expr =
extract auto expr <|> (migrateConfig expr >>= migrate)
pure (migrateConfig dhallExpr)
```

The `Expr Src X`

type comes from Dhall, and is just the type of parsed, typechecked, and normalized Dhall expressions. Now GHC says:

```
• Could not deduce: CompatF (Pred b) ~ Maybe
arising from a use of ‘migrateConfig’
from the context: (Interpret a, Compat a, Maybe ~ CompatF a)
bound by the type signature for:
parseCompatConfig :: forall a.
(Interpret a, Compat a, Maybe ~ CompatF a) =>
Text -> IO (CompatF a a)
```

`CompatF (Pred b) ~ Maybe`

means that we need to know that the `Compat`

instance for `Pred a`

must also use `Maybe`

for `CompatF`

. No problem, let’s just add that constraint:

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
dhallExpr <- inputExpr t
let migrateConfig :: ( Interpret b
, Compat b
, Maybe ~ CompatF b
, Maybe ~ CompatF (Pred b)
)
=> Expr Src X -> Maybe b
migrateConfig expr =
extract auto expr <|> (migrateConfig expr >>= migrate)
pure (migrateConfig dhallExpr)
```

Now GHC says:

```
• Could not deduce: CompatF (Pred (Pred b)) ~ Maybe
arising from a use of ‘migrateConfig’
from the context: (Interpret a, Compat a, Maybe ~ CompatF a)
bound by the type signature for:
parseCompatConfig :: forall a.
(Interpret a, Compat a, Maybe ~ CompatF a) =>
Text -> IO (CompatF a a)
```

No problem, let’s just add that constraint:

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
dhallExpr <- inputExpr t
let migrateConfig :: ( Interpret b
, Compat b
, Maybe ~ CompatF b
, Maybe ~ CompatF (Pred b)
, Maybe ~ CompatF (Pred (Pred b))
)
=> Expr Src X -> Maybe b
migrateConfig expr =
extract auto expr <|> (migrateConfig expr >>= migrate)
pure (migrateConfig dhallExpr)
```

Now GHC says:

```
• Could not deduce: CompatF (Pred (Pred (Pred b))) ~ Maybe
arising from a use of ‘migrateConfig’
from the context: (Interpret a, Compat a, Maybe ~ CompatF a)
bound by the type signature for:
parseCompatConfig :: forall a.
(Interpret a, Compat a, Maybe ~ CompatF a) =>
Text -> IO (CompatF a a)
```

If we continue we’ll have an infinitely large constraint, which isn’t that useful. Let’s try adding the constraint to `Compat`

instead:

```
class (Compat (Pred a), CompatF a ~ CompatF (Pred a)) => Compat a where
type Pred a :: *
type CompatF a :: * -> *
migrate :: Pred a -> (CompatF a) a
```

But this is just a shorter way of writing the same infinite constraint from earlier, and GHC isn’t fooled:

```
• Superclass cycle for ‘Compat’
one of whose superclasses is ‘Compat’
Use UndecidableSuperClasses to accept this
```

Even if we allowed such an infinite constraint, that’s not quite what we want. Eventually the chain of `Compat`

instances will end with our original schema type. What’s needed is a way to enforce the `Compat (Pred a)`

constraint only when `a`

is not the final type in the `Compat`

chain. This is where the `ConstraintKinds`

extension comes in.

Anything that is allowed to appear to the left of `=>`

can be manipulated like any other type when `ConstraintKinds`

is enabled. These new types have the kind `Constraint`

. They can be aliased with type synonyms, used in type families, and used as arguments to type constructors that have the appropriate kind signature. It’s this last bit that will give us the behavior we need for `Compat`

.

For a given constraint, we can build a value that represents a proof of that constraint. Consider `Functor`

as an example:

```
data FunctorProof f where
FunctorProof :: ((a -> b) -> f a -> f b) -> FunctorProof f
listFunctorProof :: FunctorProof []
listFunctorProof = FunctorProof map
maybeFunctorProof :: FunctorProof Maybe
maybeFunctorProof = FunctorProof (\f m -> maybe Nothing (Just . f) m)
functorFunctorProof :: Functor f => FunctorProof f
functorFunctorProof = FunctorProof fmap
```

A value of type `FunctorProof f`

is as good as the constraint `Functor f =>`

, and indeed this `FunctorProof`

type contains exactly the function that would be inside a `Functor`

dictionary after GHC lowers Haskell to Core. With `ConstraintKinds`

, we can make a general proof type that works for any constraint:

```
data Proof :: Constraint -> * where
Proof :: a => Proof a
type FunctorProof f = Proof (Functor f)
functorFunctorProof :: Functor f => FunctorProof f
functorFunctorProof = Proof
```

This simple data type let’s us do something interesting: we can build a `Proof`

in a scope where we know the constraint is true, pass the value elsewhere, and then unpack the value to take advantage of the constraint. Edward Kmett’s constraints package calls this type `Dict`

instead of `Proof`

(we’ll switch to `Dict`

from here on out), and includes lots of utilities for doing useful things with these proofs.

Back to the `Compat`

class. The behavior we want is the enforcement of the constraint `(Compat (Pred a), CompatF a ~ CompatF (Pred a))`

when the `Compat`

instance is not for the original schema type. This constraint ensures the connectedness of of the chain of `Compat`

instances, and also guarantees that the same type is used for `CompatF`

for each instance in the chain. The final link in the chain will be a `Compat`

instance for the original schema type. There can be no meaningful `Compat (Pred a)`

instance for this final type, so the chain continuity constraint must be elided for the final instance.

One way to achieve this is to add an optional `Dict`

to the `Compat`

class:

```
class Compat a where
type Pred a ::
type CompatF a :: * -> *
migrate :: Pred a -> (CompatF a) a
continue :: Proxy a
-> Maybe (Dict ( Compat (Pred a)
, CompatF a ~ CompatF (Pred a)
)
)
```

The `continue`

method now allows each instance to provide its own proof of the continuity constraint if it’s available. The instances for `Config2`

and `Config3`

now look like this:

```
instance Compat Config2 where
type Pred Config2 = Config1
type CompatF Config2 = Maybe
migrate c1 = Just $ Config2 {
conf2FilePath = conf1FilePath c1
, conf2UserName = Just (conf1UserName c1)
, conf2Workers = conf1Workers c1
, conf2HostName = "http://example.com"
}
continue _ = Just Dict
instance Compat Config3 where
type Pred Config3 = Config2
type CompatF Config3 = Maybe
migrate c2
| conf2HostName c2 == "http://example.com" =
Just $ Config3 {
conf3FilePath = conf2FilePath c2
, conf3UserName = conf2UserName c2
, conf3Workers = conf2Workers c2
}
| otherwise = Nothing
continue _ = Just Dict
```

We’ll now need an instance for `Config1`

as well, although it doesn’t do much:

```
instance Compat Config1 where
type Pred Config1 = Void
type CompatF Config1 = Maybe
migrate _ = Nothing
continue _ = Nothing
```

Since `Config1`

is our original schema, there’s no meaningful values of type `Pred Config1`

, so the uninhabited type `Void`

is suitable. `CompatF Config1`

needs to match our other instances. There are no preceding values, so `Migrate`

can only return `Nothing`

. And because this is the final link in the `Compat`

chain, we have no proof of the continuity constraint, so there’s no `Dict`

we can return from `continue`

.

In the definition of `parseCompatConfig`

, we need to use `continue`

to check if the constraints for `migrateConfig`

’s recursive case can be proven. If the current `Compat`

instance provides a proof, we can recurse, otherwise we’re at the end of the compatibility chain.

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
dhallExpr <- inputExpr t
let migrateConfig :: forall b.
( Interpret b
, Compat b
, Maybe ~ CompatF b
)
=> Expr Src X -> Maybe b
migrateConfig expr =
let next = case continue (Proxy :: Proxy b) of
Just Dict -> migrateConfig expr >>= migrate
Nothing -> Nothing
in extract auto expr <|> next
pure (migrateConfig dhallExpr)
```

The `forall`

in the type signature comes from the `ScopedTypeVariables`

language extension. This extension is needed because the `b`

in the `Proxy :: Proxy b`

signature is the same `b`

from the `migrateConfig`

type signature, not a new universally quantified `b`

. Unfortunately, GHC still doesn’t like this:

```
• Could not deduce (Interpret (Pred b))
arising from a use of ‘migrateConfig’
from the context: (Interpret a, Compat a, Maybe ~ CompatF a)
bound by the type signature for:
parseCompatConfig :: forall a.
(Interpret a, Compat a, Maybe ~ CompatF a) =>
Text -> IO (CompatF a a)
```

The `Dict`

that may be provided by the `continue`

method does not provide a proof of `Interpret (Pred b)`

. In general, there may be arbitrary additional constraints that are necessary to obtain a value of each schema type. When using Dhall to parse the configurations, `Interpret`

is a necessary constraint, so in addition to a proof of `Compat (Pred a)`

, a proof of `Interpret (Pred a)`

is needed. There’s no need to settle on `Interpret`

in particular; the `Compat`

class can check that any constraint holds for the entire compatibility chain:

```
class Compat a where
type Pred a :: *
type CompatConstraint a :: * -> Constraint
type CompatF a :: * -> *
migrate :: Pred a -> (CompatF a) a
continue :: Proxy a
-> Maybe (Dict ( Compat (Pred a)
, (CompatConstraint a) (Pred a)
, CompatConstraint a ~ CompatConstraint (Pred a)
, CompatF a ~ CompatF (Pred a)
)
)
```

`CompatConstraint`

is a new associated type that provides any additional constraints needed to obtain the values of each schema. `continue`

now also provides a proof of `(CompatConstraint a) (Pred a)`

(which provides whatever is needed to obtain configuration values), and `CompatConstraint a ~ CompatConstraint (Pred a)`

, which ensures that the same `CompatConstraint`

is used for each instance in the `Compat`

chain. Here are the updated instances:

```
instance Compat Config1 where
type Pred Config1 = Void
type CompatConstraint Config1 = Interpret
type CompatF Config1 = Maybe
migrate _ = Nothing
continue _ = Nothing
instance Compat Config2 where
type Pred Config2 = Config1
type CompatConstraint Config2 = Interpret
type CompatF Config2 = Maybe
migrate c1 = Just $ Config2 {
conf2FilePath = conf1FilePath c1
, conf2UserName = Just (conf1UserName c1)
, conf2Workers = conf1Workers c1
, conf2HostName = "http://example.com"
}
continue _ = Just Dict
instance Compat Config3 where
type Pred Config3 = Config2
type CompatConstraint Config3 = Interpret
type CompatF Config3 = Maybe
migrate c2
| conf2HostName c2 == "http://example.com" =
Just $ Config3 {
conf3FilePath = conf2FilePath c2
, conf3UserName = conf2UserName c2
, conf3Workers = conf2Workers c2
}
| otherwise = Nothing
continue _ = Just Dict
```

Here’s the updated definition of `parseCompatConfig`

:

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
, Interpret ~ (CompatConstraint a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
dhallExpr <- inputExpr t
let migrateConfig :: forall b.
( Interpret b
, Compat b
, Maybe ~ CompatF b
, Interpret ~ (CompatConstraint b)
)
=> Expr Src X -> Maybe b
migrateConfig expr =
let next = case continue (Proxy :: Proxy b) of
Just Dict -> migrateConfig expr >>= migrate
Nothing -> Nothing
in extract auto expr <|> next
pure (migrateConfig dhallExpr)
```

Now we have a working `parseCompatConfig`

. This function may be passed a configuration file encoding a `Config1`

, a `Config2`

, a `Config3`

, or any future schema, and will (if possible) return a value of the latest schema with a `Compat`

instance.

Consider the `migrateConfig`

definition. The only Dhall-specific parts of the definition are the `Interpret`

constraints, the `expr`

argument, and what we do with it. `extract auto expr`

returns something of type `forall a. Interpret a => Maybe a`

. Both the `Interpret`

constraint and the `Maybe`

type constructor are also stipulated by our `Compat`

instances. We can generalize the `Interpret`

constraint by simply using the `CompatConstraint`

associated type, and we can generalize `Maybe`

by using the `CompatF`

associated type, with the addition of `Alternative`

and `Monad`

constraints, since we want to use `<|>`

and `>>=`

with the `CompatF`

type. `migrateConfig`

can now be replaced with the much more general `getCompatible`

:

```
getCompatible
:: forall a.
( Compat a
, (CompatConstraint a) a
, Alternative (CompatF a)
, Monad (CompatF a)
)
=> (forall c. (Compat c, (CompatConstraint a) c) => (CompatF a) c)
-> (CompatF a) a
getCompatible f =
let f' = case continue (Proxy :: Proxy a) of
Nothing -> empty
Just Dict -> getCompatible f >>= migrate
in f <|> f'
```

This will work with any chain of `Compat`

instances where `CompatF`

has `Alternative`

and `Monad`

instances. Defining `parseCompatConfig`

in terms of `getCompatible`

:

```
parseCompatConfig :: ( Interpret a
, Compat a
, Maybe ~ (CompatF a)
, Interpret ~ (CompatConstraint a)
)
=> Text
-> IO ((CompatF a) a)
parseCompatConfig t = do
expr <- inputExpr t
pure (getCompatible (extract auto expr))
```

I’ve found this `Compat`

class works well for managing changing configuration schemas. This particular implementation has a few practical drawbacks. In the worst case, parsing values from older schemas has a cost linear in the length of the `Compat`

chain. This cost will likely be unnoticeable for parsing configuration files, but isn’t ideal for anything larger-scale. In the latter case, incorporating a version tag in the data schema that tells the parser which schema revision to try should yield better performance. The version tags could be computed from the `Compat`

chain automatically with type-level naturals, and type families could be used to give `migrate`

a function type that accepts any predecessor value, instead of the most recent predecessor, allowing the migration chain to be short-circuited when possible for the sake of performance.

A potential semantic pitfall with this approach is that the parser used to yield configuration values to `getCompatible`

(which is `extract auto`

in the examples in this post) might return early if it is unable to distinguish between an older and a newer schema. Consider these two problematic schemas:

Suppose that our configuration has an IP address field. In the first version of the schema, the IP address must be an IPv4 address. In the (very stingy) second schema revision, the IP address must be an IPv6 address. Since both schema types simply use a `String`

to represent IP addresses, a naive parser that can’t tell the difference between an IPv4 and IPv6 address will always return a `Bad2`

value, even if the configuration file contains an IPv4 address. The `Compat`

class as written here offers no help in this situation, since it relies on parse failure to walk up the migration chain. This situation may be avoided by always using the richest, most specific schema types possible.

A tiny Haskell package providing `Compat`

is available on GitHub