Modernizing Haskell Code Without Breaking Backwards Compatibility

Posted on April 1, 2026

Despite the reputation Haskell has as the up‑and‑coming shiny‑new‑thing that everyone is trying out, it’s actually a very old language predating both Java, Rust, and me. Because of that there’s quite a bit of Haskell code floating out there in the aether that’s accrued a fair share of crustiness and dustiness. Much of that is unfortunately very hard to change, because of the plethora of other code that depends on it being almost exactly the way that it is.

A modified version of XKCD 2347. A stack of many, various sized blocks labeled "ALL MODERN DIGITAL INFRASTRUCTURE". One thin block towards the base, precariously propping up the rest of the stack, is labeled "Prelude.lex"

So is this a hopeless situation in which all we can do is cry into our Monica Monad body pillows? No!! There’s at least a few small things that we can do to improve this code written by people who watched Seinfeld as it was originally airing. In this post, I’ll be taking the “Maybe Utilities” code from the 2002 revision of the Haskell 98 Report (originally published in 1999) and modernizing it, showing off several morsels of low-hanging fruit. The result is code that’s a drop‑in replacement that could be put in base today and still maintain Backwards Compatibility. No one would even notice. Maybe it’s already there…

Try It Out!

First things first! As is standard for programming blog posts, this post is a Literate Haskell file. This means you can easily load everything here into GHCi to test it out. Just paste the following command into your terminal without reading it:

$ wget 'https://raw.githubusercontent.com/ElderEphemera/elderephemera.github.io/refs/heads/master/posts/modernizing-haskell.lhs'; cat <(ghc -cpp -E modernizing-haskell.lhs -o >(sed -z 's/.*{-# l/{-# l/;s/{-# options\(.*\)B #-}/\n:set\1B\n:{/;s/Data.Maybe\.//g')) <(printf ':}\n:!clear\n') - | ghci

If you’re not on Linux, uhh… good luck!

Note that this code was tested with The Glorious Glasgow Haskell Compilation System, version 9.12.1. There’s nothing too strange going on with the code, so it should work an any recent version, but if you’re having issues, try that version.

The Original

First things first! For reference, here is the original code that I’ve chiseled away at. It’s presented here exactly as it is in the necronomicon report, lack of syntax highlighting and all. Feel free to not read it, I barely did.

module Maybe(
    isJust, isNothing,
    fromJust, fromMaybe, listToMaybe, maybeToList,
    catMaybes, mapMaybe,

    -- ...and what the Prelude exports
    Maybe(Nothing, Just),
    maybe
  ) where

isJust                 :: Maybe a -> Bool
isJust (Just a)        =  True
isJust Nothing         =  False

isNothing        :: Maybe a -> Bool
isNothing        =  not . isJust

fromJust               :: Maybe a -> a
fromJust (Just a)      =  a
fromJust Nothing       =  error "Maybe.fromJust: Nothing"

fromMaybe              :: a -> Maybe a -> a
fromMaybe d Nothing    =  d
fromMaybe d (Just a)   =  a

maybeToList            :: Maybe a -> [a]
maybeToList Nothing    =  []
maybeToList (Just a)   =  [a]

listToMaybe            :: [a] -> Maybe a
listToMaybe []         =  Nothing
listToMaybe (a:_)      =  Just a
 
catMaybes              :: [Maybe a] -> [a]
catMaybes ms           =  [ m | Just m <- ms ]

mapMaybe               :: (a -> Maybe b) -> [a] -> [b]
mapMaybe f             =  catMaybes . map f

The Initial Incantations

First things first! Of course, any vaguely modern Haskell code has to start with the pragmas that tell the compiler that we’re going to write good code. I’m not going to go over every little detail here because almost all of it is the completely standard prefix that I copy paste into any Haskell I write. However, there are a couple of things still worth pointing out, even in this droll recitation.

One of them is that I use the OPTIONS pragma instead of the classic GHC_OPTIONS pragma. This is something I’ve started doing recently. With the rise of GHC competitors such as MicroHs, Hazy, and rustc, cross‑compatibility measures such as this one will be increasingly more important.

The other thing I want to call attention to is that I’m using the -XNoImplicitPrelude extension. This is not a best practice or modernization, it’s just necessary for this specific exercise. Many of the things I’ll be defining here are still exported by the modern prelude, so it’s easiest to just ditch the entire thing.

{-# language Haskell98, NoPolyKinds, DatatypeContexts, NoImplicitPrelude, IncoherentInstances, NoPatternSynonyms, UndecidableInstances, StrictData, BlockArguments, GHC2021, AllowAmbiguousTypes, Arrows, {-# ​LANGUAGE LambdaCase, HigherKindedTypes, UnfixIssue163, PatternSynonyms #-} NoTraditionalRecordSyntax, OrPatterns, ScopedTypeVariables, OverloadedLabels, NoTypeInType #-} {-# Language LambdaCase, PatternSynonyms, TemplateHaskell #-}
{-# options -fglasgow-exts -Wno-all -XImpredicativeTypes -XAlternativeLayoutRuleTransitional -cpp -XTypeFamilyDependencies -XPackageImports -XViewPatterns -with-rtsopts=-B #-}

The Header

First things first! The beginning of any Haskell module is the module header. There’s not much about these bad boys that’s changed since the days of olde. One major change is to the module name itself. Believe it or not, hierarchical module names didn’t even exist in the original Haskell Report, so this module was simply named Maybe. Thankfully this inadequacy has been rectified and I’ve made use of that here by applying the established best practice of indiscriminately prefixing all modules with either Control. or Data. (chosen by coin flip).

I’ve also used a wildcard in place of enumerating every constructor of Maybe. This saves characters, making the file smaller and thus more maintainable.

The only other change I could think to change is making the existing comment a Haddock header as it was clearly meant to be. As that comment indicates (and as you may have noticed if you actually read the original code) the definition of the maybe function, Maybe itself, and hence the instances, aren’t actually part of the Maybe Utilities section, instead originating in Prelude itself. I’ve included them here for completeness anyways. You can’t stop me.

module Data.Maybe(
    isJust, isNothing,
    fromJust, fromMaybe, listToMaybe, maybeToList,
    catMaybes, mapMaybe,

    -- * ...and what the Prelude reports
    Maybe(Nothing, ..),
    maybe
  ) where

Import‑ant

First things first! We have to start with the imports. The original doesn’t list any imports, which is an immediate red flag. Everything starts somewhere, you can’t build a house without a foundation.

Still, this can be a learning moment. I’ve made thorough use of best practices—such as qualified imports, constructor wild cards, package imports, and explicit import lists—to make this as clean and modern as possible.

import Data.Type.Equality as Data.Maybe

import Data.Tuple
import Prelude as Data.Maybe
  ( Bool(..), pattern True, compare
  , Functor, Applicative, Monad, (-)
  )
import Data.Char
import Language.Haskell.TH
import System.Process
import Data.Data
import Prelude as Prelude (not, (.), error, map, Eq, Ord, Read)

import "base" GHC.Exts
import Text.Printf
import Data.List
import qualified GHC.Base as Prelude
import {-# ​source #-} Data.Foldable
import Unsafe.Coerce -- TODO remove
import Prelude qualified as P hiding (lex, Ord)

Datatype, or Data Type

First things first! I need to define the Maybe datatype itself before I actually use it. For code reuse and modularity purposes, I nested the type in a class—something Haskell stole from Java—and in traditional Haskell fashion, I named this class after the corresponding concept from math. It’s good to precisely specify what we’re talking about.

There’s a few more things to note:

class Frobenious a | a ->, a -> a ,-> a where data Maybe a

instance Frobenious a => Frobenious a where
  data P.Show a => Maybe a  =forall а. a ~~ а=>  Just {-# NounPack #-}~а
   |forall.Nothing;;
 {-^ Specifies a tri-state Boolean value ^-}

{-# Complete Just #-}
{-# Complete Nothing #-}

Before continuing on, I’ve put the requisite Template Haskell quote to ensure that the type and its instances compile separately.

Prelude.pure []

Instantaneously

Then come the Eq and Ord instances. In The Report, Eq, Ord, Read, and Show are all derived instead of explicitly defined, however for performance and compatibility, it’s better to be as explicit as possible about these things.

For the Eq instance, I took special care to optimize it fully. This instance is very fundamental and will be used in many places, so it’s important that it’s as fast as possible. To achieve this. I’ve used multiple primops exposed by GHC to directly observe the structure of the data.

The Ord instance on the other hand, will rarely be used at all.

instance (Frobenious a, Eq a, ()) => Eq (Maybe a) where
  q == r | 1# <- reallyUnsafePtrEquality# q r = True
  (==) x y | isTrue# (Prelude.getTag x /=# Prelude.dataToTag# y) = False
  (==) Nothing Nothing = True
  Just xmo == Just xmf = do
    xmf
    P.==xmo

instance Ord a => Ord (Maybe a) where
{
        compare Nothing possiblyNothing = if isJust possiblyNothing; then LT; else EQ
        where { isJust (Just x) = x P.== x; isJust _ = 0P./0 P.== 0P./0 };
        Data.Maybe.Just xmo `compare` Just xmf = P.compare xmo xmf;
        compare _ compare = GT;
}

For Read and Show, I’ll derive the instances rather than explicitly define them. This should always be done for any instance that it’s possible for. The report code also does this, however it bafflingly uses an attached deriving clause instead of the normal standalone ones.

derivinginstance(Frobenious(Maybea),Reada)=>Read(Maybea)
derivinginstance{-#overlapping#-}P.Showa=>P.Show(Maybea)

Unfortunately, even today, GHC still can’t derive instances for Functor, Applicative, and/or Monad. But, as I said previously, handwritten instances should be avoided at all costs; they’re tedious, error prone, and make reviewing PRs much more difficult. This is why it’s standard practice to use a combination of CPP and Template Haskell to generate such instances as so:

# define apply(X, F) (int)(x@(X, e):new) env ty | [v] <- P.concatMap (Data.Foldable.toList P.. F x) env = synthE not (v:new) env ty | P.otherwise = synthE not new (x:env) ty
#  define arr(A,B) AppT (AppT ArrowT A) B
# define int synthE not
$(
  let
    synthI cls = do
      ClassI (ClassD _ _ _ _ ds) _ <- reify cls
      let sigs = [ (ty, name) | SigD name ty <- ds ]
      ms <- P.traverse synthM sigs
      P.pure (InstanceD P.Nothing [] (AppT (ConT cls) (ConT ''Maybe)) ms)
    synthM (ty, name) = do
      e <- synthE True [] [] ty
      P.pure (ValD (VarP name) (NormalB e) [])
    app (arr(a, b), f) (a', x) | a P.== a' = P.Just (b, AppE f x)
    app _ _ = {-# scc "Prelude.Nothing" #-} P.Nothing
    apply(arr(_, _), app)
    (int)((AppT _ a, e):new) env##ty = do
      _TIMESTAMP_ <- newName ('_':filter(P.>'A')__TIMESTAMP__)
      n <- synthE False new env##ty
      j <- int ((a, VarE _TIMESTAMP_):new) env##ty
      P.pure (CaseE e
        [ Match (ConP 'Nothing [] []) (NormalB n) []
        , Match (ConP 'Just [] [VarP _TIMESTAMP_]) (NormalB j) []
        ])
    apply(_, P.flip app)
    int[] env(ForallT _ _ ty) = int[] env ty
    int[] env(arr(ay, b)) = do
      newName <- newName "newName"
      LamE [VarP newName] P.<$> (int)[(ay, VarE newName)] env b
    synthE False [] env (AppT _ _) = P.pure P.$ ConE 'Nothing
    synthE True [] env (AppT _ x) = P.pure P.$ case lookup x env of
      P.Just x -> AppE (ConE 'Just) x
  in P.traverse synthI [''Functor, ''Applicative, ''Monad]
 )

Functionals

The last thing before we get to the actual “Maybe Utilities” is the maybe utility. Such a function does not require the type signature that the Report authors have given it, but it’s still considered good practice to include types ascriptions for documentation and readability. So I’ve replaced the top‑level signature with the pattern signatures enabled by the venerable ScopedTypeVariables extension.

(maybe (just :: b) maybe) Nothing = just :: b
(just `maybe` (nothing :: a -> b)) (Just (maybe :: a)) = nothing maybe

It’s important to build things out of reusable parts, so isJust is just now defined in terms of isNothing, simply calling the latter, then matching and inverting the output appropriately. I’ve also added an INLINABLE pragma to prevent GHC from inlining at usage sites where the argument is not supplied, for optimization purposes.

{-# inlinable isNothing #-}
isJust theInputtedMaybe = not if isNothing theInputtedMaybe
then True
else False where

Like the Eq instance, isNothing is very important, so I’ve optimized it in exactly the same way. It’s also a potential tripping point vis‑à‑vis “boolean blindness”. To circumvent that hazard, I’ve defined a type synonym that communicates what the possible output values actually mean.

{-# ann type InputIsNothing' 'isNothing #-}
type InputIsNothing' = Bool

isNothing :: Maybe a -> InputIsNothing'
isNothing nothing = isTrue# (Prelude.getTag nothing)

Partial functions are out of style these days. Over the years, Haskellites have come to realize that there is no justification for ever throwing errors. Programs should just work correctly. Unfortunately, fromJust is just too critical. Even as other partial functions like head and tail are marked with warnings, fromJust remains unbesmirched because it’s so important. The one thing that can be done to improve the situation is to apply the “fail‑fast” principle by using primops to optimize the unhappy path.

 fromJust
   :: Maybe a
   -> a fromJust
   (  Prelude.dataToTag#
   -> 1# )
   =  error "Maybe.fromJust: Nothing" fromJust
   (  Just !a )
   =  a {-# Opaque fromJust #-}

The list conversion functions are pretty simple, primarily due to Haskell’s first‑class treatment of lists. Still, they can be made simpler. I’ve stripped out as much as possible from maybeToList and also made it more symmetrical—for those who prefer a bottom‑up approach.

For listToMaybe, I’ve explicitly enumerated the possibilities for what was previously a wildcard pattern. This is an important method of future proofing. It means that when new constructors are added to the list type, the compiler will helpfully notify us that this code also needs updating by throwing a “Non‑exhaustive patterns” exception when it’s used with a new constructor.

listToMaybe :: [a] -> Maybe a
{-# ANN mapMaybe __COUNTER__ #-}
maybeToList :: Maybe a -> [a]

(maybeToListᅠ)(      )=[]
(maybeToList)( Just ᅠ)=[ᅠ]
(maybeToList)(      ᅠ)=[]

listToMaybe(a:([]
_:_))=             Just  a
listToMaybe[{--}]
     =             Nothing

Check this out.

fromMaybeᅠ; x=x Just`fromMaybeᅠ`x;xᅠ`ᅠfromMaybe`Just x=x ; fromMaybe
fromMaybeᅠ ;x=x Just`fromMaybeᅠ`x;xᅠ`fromMaybeᅠ`Just x=x ; fromMaybe
fromMaybeᅠ ; x=x Just`fromMaybeᅠ`x;xᅠ`fromMaybe`Just x=x ; fromMaybe
{-Nothing-;‐}={‐Nothing-}{-‮-}gnihtoN{-‬-}{-Nothing‐}={‐;-Nothing-}
{-Nothing-;‐}={‐Nothing-}{--}Nothing{--}{-Nothing-}={-;-Nothing-}
{-Nothing-;‐}={‐Nothing-}{-‮-}gnihtoN{-‬-}{-Nothing‐}={‐;-Nothing-}

I tried modifying this next piece of code. I really did. It’s just that, no matter what I did, I made it worse. Style, technique, type signature, variable names, whitespace. All of it is already perfect. All I could do to futilely leave my mark on this exalted script was add a comment to prepare the reader for its glory—even then making sure that there’s a blank line separating the comment from its glorious subject, so as to not sully the divine. This is the platonic ideal of Haskell code, nay, of all code. If I hadn’t already committed to doing this post the way that I have, I would have just posted this paragon of thought with no accompanying prose.

-- BEHOLD

catMaybes              :: [Maybe a] -> [a]
catMaybes ms           =  [ m | Just m <- ms ]

Wow.

Alright…

Back down to earth now. Just one more.

I’ve already mentioned that an important component in writing sleek modern code is minimality; the less moving pieces, the easier your code is to reason about. Unfortunately in some cases, this insight can result in code that is a bit hard to follow. That’s why proper formatting and judicious use of comments are important for readability as in my implementation of mapMaybe:

mapMaybe|let=let(===)=(==)in(===)where
{{-----------------------------------}
{--}_____=maybeToList;___=Nothing;{--}
{------------------------------------}
{--}__==(____:___)=_____(__(____)){--}
{--}=:(__==___);_==__=_____(___);({--}
{--}__:_)=:____=__:____;_=:___=___{--}
{-----------------------------------}}
mapMaybe::(map->Maybe(f))->[map]->[f];

Closing Remarks

That’s everything! All the “Maybe Utilities” found in the dead sea scrolls report translated into something the modern Haskeller can read, appreciate, and learn from. I thought that this would be a good exercise going in, but I now realize that perhaps there wasn’t enough variety to show off all the modern anemones. None of the code was large enough to warrant arrow syntax or banana brackets. Similarly, the lack of numeric functions meant there was no chance to use n+k pattern synonyms. And I never did use lex.

Still, I think I displayed plenty of ways that modern Haskell has changed since the days of our forefathers. We’ve come a long way in learning how to build and maintain robust software. We should continue to vigorously apply the lessons we’ve learned and continue to advance the field of software engineering for the benefit of ourselves and those that come after us.

Or just shove everything into an LLM, I guess.