– An experience report
At work (Groq) we recently upgraded the version of GHC that we use from 8.10 to 9.6, along with many of the Haskell packages we depend on. Some of the changes to GHC and the packages we depend on were “breaking changes”, that is, changes which forced us to change our own code in response. This document details all such changes that we had to make to our own code. Hopefully it serves as a measure of the effort required to keep up with breaking changes in the Haskell ecosystem, encourages library and compiler maintainers to avoid making breaking changes where reasonable, and where breaking changes are made, to make them in a way that allows forward-compatible mitigations rather than breaking fixes.
I distinguish two kinds of behavior-preserving changes that are
required in response to an upgrade which contains breaking changes:
“forward-compatible mitigations” and “breaking fixes”.
Foward-compatible mitigations are those changes that can be made to
the codebase before the upgrade such that no further changes need to
be made at the same time as the upgrade. For example, suppose that
library “mylibrary
” version 1 exports functionOld
, which is
deprecated, and functionNew
which is identical except it is not
deprecated. Further suppose that mylibrary
version 2 removes
functionOld
. A forward-compatible mitigation would be to replace
uses of functionOld
with functionNew
. Subsequently, the code
works equally well with mylibrary
version 1 and with mylibrary
version 2, so no change needs to be made at the same time as the
upgrade of mylibrary
from version 1 to version 2.
By contrast, breaking fixes are those changes that only work after
an upgrade. For example, suppose that library “anotherlibrary
”
version 1 exports anotherFunction :: A -> B -> C
, and
anotherlibrary
version 2 exports anotherFunction :: B -> A -> C
,
which is identical except that the argument order is switched. A
breaking fix would be to replace all uses of anotherFunction
by
flip anotherFunction
. Breaking fixes complicate an upgrade because
they can’t be made before or after the upgrade but must be made at
exactly the same time.
Every breaking fix can be made forward-compatible by wrapping it in
CPP, but that introduces its own complexity, not least because CPP
only works on a per-line basis rather than a per-expression basis. We
didn’t use CPP in our upgrade. Another technique for making some
breaking fixes into forward-compatible mitigations is the
“compatibility shim”. For example, in the case of anotherlibrary
above, the author could have added anotherFunctionNew :: B -> A -> C
to version 1 and retained it in version 2, allowing users to make the
forward-compatible mitigation of switching from anotherFunction
to
anotherFunctionNew
before the upgrade. At their leisure, any time
after the upgrade, they could switch to flip anotherFunction
.
Critically, they are not forced to make the switch at the same time
as the upgrade1.
Prelude
Newer versions of base
re-export liftA2
from the Prelude
therefore explicit imports from Control.Applicative
emit warnings.
The forward-compatible mitigation is to turn on
-Wwarn=unused-imports
but we chose the breaking fix of removing
the explicit import.
ST
instance of MonadFail
removedThe instance MonadFail (ST s)
was removed in a recent base
version. The forward-compatible mitigation is to use error
instead of fail
when in ST
.
mtl
modulesSome mtl
modules, for example Control.Monad.Except
, previously
re-exported transformer-related functionality such as lift
and
MonadTrans
. More recent mtl
versions do not so the imports must
come from elsewhere, such as Control.Monad.Trans
. This is a
forward-compatible mitigation.
aeson
API changeVersion two of aeson
changed its representation of JSON objects from
HashMap Text Value
to KeyMap Value
because the former is
vulnerable to denial-of-service attacks. The breaking fix is to use
the new API, and conversion functions where necessary, for example
Data.Aeson.KeyMap.lookup
instead of Data.HashMap.Strict.lookup
,
and fromText
and toText
to convert between Text
and Key
.
Parts of the breaking fix can be made forward-compatible by clever
use of imports and local definitions as a sort of compatibility shim.
For example, if you write this code, which works under aeson-1
:
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson (Value)
import Data.HashMap.Strict qualified as Aeson
type Key = Text
type KeyMap = Aeson.HashMap Key
fromText :: Text -> Key
= id
fromText
lookupColor :: KeyMap -> Value
= Aeson.lookup (fromText "color")
lookupColor m
... more code
then converting it to aeson-2
only requires tweaking imports, not
the bulk of the code:
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson (Value)
import Data.Aeson.Key (Key, fromText)
import Data.Aeson.KeyMap (KeyMap)
import Data.Aeson.KeyMap qualified as Aeson
lookupColor :: KeyMap -> Value
= Aeson.lookup (fromText "color")
lookupColor m
... more code (unchanged)
A team could reduce the churn even further by creating a compatibility
module in their codebase, Data.Aeson.Compat
say, which contains
those definitions. Then the client code would look like the following
and not need to change at all when switching to aeson-2
! Only
Data.Aeson.Compat
would need to change.
{-# LANGUAGE OverloadedStrings #-}
import Data.Aeson (Value)
import Data.Aeson.Compat qualified as Aeson
import Data.Aeson.Compat (Key, KeyMap)
lookupColor :: KeyMap -> Value
= Aeson.lookup (fromText "color")
lookupColor m
...
In fact, this kind of compatibility module could have been placed in
an aeson-compat
package on Hackage for everyone to use, or even
officially included in aeson
itself.
I64#
changeThe definition of Int64
used to
be
data Int64 = I64# Int#
but more recently it
became
data Int64 = I64# Int64#
. This requires a breaking fix. In our
case we shouldn’t have been using this low-level representation in the
first place, and it turned out the code that was using it was not
itself used, so we deleted it (which luckily for us is a
forward-compatible mitigation).
xls
API changeThe xls
library introduced RowIndex
and ColumnIndex
abstract
types where it previously used Int
. As with aeson
this required a
breaking fix which could be implemented partially as a
forward-compatible mitigation by defining type synonyms locally as a
sort of compatibility shim. The upgrade would have been easier if the
library itself had introduced those synonyms before making the
breaking change.
The library also added wsState
as a field to its Worksheet
type,
which required a breaking fix when constructing values of that type.
flatparse
API changeanyCharASCII
became
anyAsciiChar
,
along with many similar API changes. If we had a large amount of
flatparse
code we might have used the compatibility module approach
in order to make a partially forward-compatible mitigation but
instead we made the breaking fix.
constraints-extras
API changeIn previous versions of
constraints-extras
Has
was a type synonym whereas in later versions it became a type
class.
This required a breaking fix.
genSingletons
We couldn’t get
genSingletons
,
from the singletons
family of packages, to work after the upgrade.
The breaking fix we applied was to write out by hand what would have
been generated by Template Haskell.
GHC 9.0 made a switch to a type inference scheme for higher rank
polymorphism called simplified
subsumption.
Consquently, expressions like g f
in the below, which passed the
type checker in the GHC 8 series, stopped passing the type checker in
the GHC 9 series.
{-# LANGUAGE RankNTypes #-}
f :: Int -> (forall a. a -> a)
= id
f _
g :: (forall a. Int -> a -> a) -> ()
= ()
g _
main :: IO ()
= print (g f) main
The forward-compatible mitigation is to manually eta-expand f
to
obtain g (\x -> f x)
, as in the below.
= print (g (\x -> f x)) main
The breaking fix is to disable simplified subsumption by using the
DeepSubsumption
language pragma (available in GHC 9.2 and later).
We came across a bug where GHC errored out with “The impossible
happened” in some code related to existential types. We had to pull
an inner binding to the top-level and mark it NOINLINE
to avoid
triggering the bug. This was a forward-compatible mitigation.
Improvements to GHC’s pattern match checker mean that some patterns
are now detected as redundant where they weren’t previously. The
forward-compatible mitigation of using -Wwarn=overlapping-patterns
has too big a downside so we used the breaking fix of removing the
redundant patterns.
*
becomes Type
The GHC 9 series introduces a warning in -Wall
about using *
as
the kind of types. The forward-compatible mitigation is to replace
*
with Data.Kind.Type
.
The GHC 9 series introduces a warning in -Wall
about pattern
matching on a GADT without MonoLocalBinds
. The forward-compatible
mitigation is to enable MonoLocalBinds
. Alternatively there is a
breaking fix which is to disable the gadt-mono-local-binds
warning.
brittany
is no longer maintainedThe formatter brittany
is no longer maintained. We applied the
forward-compatible mitigation of no longer using brittany
to
format a subset of our code.
Hadrian, the (relatively) new GHC build system, refuses to build
ghci
when cross-compilation is enabled. We applied the
forward-compatible mitigation of no longer building cross-compiled
GHC.
The Opaleye API breakage policy is upheld by the heavy use of compatibility shims.↩︎