Typeclasses in Depth

2026-09-19 · 8 min

§ 01

Declaration, instance, and dispatch

A class, and two instances.

class Describable a where
describe :: a -> String
data Circle = Circle Double
data Rect = Rect Double Double
instance Describable Circle where
describe (Circle r) = "circle r=" <> show r
instance Describable Rect where
describe (Rect w h) = "rect " <> show w <> "x" <> show h

Dispatch is resolved at compile time from the concrete type at the call site - there is no vtable lookup at runtime, the compiler picks the instance during type checking.

ghci> describe (Circle 5)
"circle r=5.0"
ghci> describe (Rect 3 4)
"rect 3.0x4.0"

A constrained function is compiled by passing the instance's method table as a hidden argument - a dictionary. This is why constraints show up before => in every signature that uses one.

describeAll :: Describable a => [a] -> [String]
describeAll = map describe
-- roughly compiles to something shaped like:
-- describeAll :: DescribableDict a -> [a] -> [String]
-- describeAll dict = map (describe dict)

Two constraints, resolved independently - each type variable gets its own dictionary passed in.

render :: (Describable a, Show b) => a -> b -> String
render x y = describe x <> " / " <> show y
§ 02

Default methods and MINIMAL

A class with two methods where each can be defined in terms of the other - implementing either one alone gives you both.

class MyEq a where
myeq :: a -> a -> Bool
myeq x y = not (myneq x y)
myneq :: a -> a -> Bool
myneq x y = not (myeq x y)

Without MINIMAL, an instance defining neither method compiles - both defaults call each other, and calling either one loops forever at runtime rather than failing to compile.

data Coin = Heads | Tails
instance MyEq Coin
-- compiles fine; myeq Heads Heads loops forever

MINIMAL states which subset of methods an instance must define, and GHC warns at the instance declaration if it is not satisfied - the same trap the lens post hit with a naive Eq-shaped deriving anyclass instance, caught here at the class-author level instead.

class MyEq a where
{-# MINIMAL myeq | myneq #-}
myeq :: a -> a -> Bool
myeq x y = not (myneq x y)
myneq :: a -> a -> Bool
myneq x y = not (myeq x y)

Now the empty instance is flagged at compile time instead of looping at runtime.

warning: [-Wmissing-methods]
No explicit implementation for
either 'myeq' or 'myneq'

MINIMAL syntax for combinations - comma means both required, | means either, and they nest with parentheses for anything more elaborate than a flat or/and.

{-# MINIMAL foo, bar #-} both required
{-# MINIMAL foo | bar #-} either alone is enough
{-# MINIMAL foo, (bar | baz) #-} foo, plus one of bar/baz
§ 03

Superclasses

A superclass constraint on a class declaration - every Ord instance must also have an Eq instance, since ordering implies equality.

class Eq a => MyOrd a where
cmp :: a -> a -> Ordering

Inside any MyOrd instance or any function constrained by MyOrd, the Eq methods are available with no extra constraint - the superclass is implied, not re-declared.

sameOrEqual :: MyOrd a => a -> a -> Bool
sameOrEqual x y = x == y || cmp x y == EQ

An instance for MyOrd requires an Eq instance to already exist - the compiler checks the superclass is satisfiable before accepting this instance at all.

data Priority = Low | Medium | High deriving Eq
instance MyOrd Priority where
cmp Low Low = EQ
cmp Low _ = LT
cmp _ Low = GT
-- ... rest of the cases

Multiple superclasses, and a class hierarchy several levels deep - this is exactly the shape of Functor -> Applicative -> Monad in base, just with made-up names.

class (Eq a, Show a) => Describable2 a where
longDescribe :: a -> String
longDescribe x = show x <> " (eq-checkable)"
class Describable2 a => Serializable a where
serialize :: a -> String
§ 04

Multi-parameter classes and functional dependencies

A class over two type variables needs MultiParamTypeClasses - relating a container type to the element type it holds.

{-# LANGUAGE MultiParamTypeClasses #-}
class Container f a where
empty :: f a
insert :: a -> f a -> f a
toListC :: f a -> [a]

Without more information, GHC cannot infer a from f alone - a call site with an ambiguous a leaves the compiler no way to pick an instance.

instance Container [] a where
empty = []
insert = (:)
toListC = id
-- ambiguous: which `a` is meant here?
-- countElems c = length (toListC c)

A functional dependency f -> a declares that f determines a uniquely - once GHC knows the container type, it can resolve the element type without it being written down anywhere at the call site.

{-# LANGUAGE FunctionalDependencies #-}
class Container f a | f -> a where
empty :: f a
insert :: a -> f a -> f a
toListC :: f a -> [a]

A concrete use case: a class relating a monad transformer stack to the base monad it wraps, which is the actual reason fundeps exist in the wild - mtl's MonadState uses exactly this shape.

class Monad m => MonadLogger m msg | m -> msg where
logMsg :: msg -> m ()
newtype App a = App { runApp :: [String] -> (a, [String]) }
instance Functor App where
fmap f (App g) = App (\logs -> let (a, logs') = g logs in (f a, logs'))
instance Applicative App where
pure a = App (\logs -> (a, logs))
App f <*> App g = App (\logs ->
let (h, logs') = f logs
(a, logs'') = g logs'
in (h a, logs''))
instance Monad App where
App g >>= f = App (\logs ->
let (a, logs') = g logs
in runApp (f a) logs')
instance MonadLogger App String where
logMsg msg = App (\logs -> ((), logs ++ [msg]))

Now a function constrained only by MonadLogger m msg never has to mention String explicitly - m -> msg makes it derivable, so the same code works unmodified if the log-message type ever changes.

doWork :: MonadLogger m String => m Int
doWork = do
logMsg "starting"
logMsg "done"
pure 42
§ 05

Overlapping and incoherent instances

Two instances for [a] - one fully general, one specialized for Char - conflict without an explicit resolution rule.

{-# LANGUAGE FlexibleInstances #-}
class Pretty a where
pretty :: a -> String
instance Show a => Pretty [a] where
pretty xs = "[" <> show xs <> "]"
instance Pretty [Char] where
pretty s = s

Without a pragma, GHC rejects the second instance outright as soon as it could overlap the first - the ambiguity is caught at instance-declaration time, before any call site is even written.

{-# OVERLAPPING #-} on the more specific instance tells GHC to prefer it whenever both could apply - this is a per-instance pragma, not a whole-module extension.

instance Show a => Pretty [a] where
pretty xs = "[" <> show xs <> "]"
{-# OVERLAPPING #-}
instance Pretty [Char] where
pretty s = s

Which resolves as expected - the more specific instance wins for String, the general one for everything else.

ghci> pretty "hi"
"hi"
ghci> pretty [1,2,3 :: Int]
"[[1,2,3]]"

OVERLAPPABLE marks the general instance as one that is allowed to lose to something more specific - stated the other way around from OVERLAPPING, and library authors usually put this on the base case.

{-# OVERLAPPABLE #-}
instance Show a => Pretty [a] where
pretty xs = "[" <> show xs <> "]"
instance Pretty [Char] where
pretty s = s

INCOHERENT goes further: it lets GHC pick an instance arbitrarily when the choice is genuinely ambiguous, rather than reporting an error - almost never what you want, since two different modules can end up silently making different choices for the same call. Named here specifically so it is recognizable in someone else's code, not as something to reach for.

{-# INCOHERENT #-} on an instance means: if resolution is still
ambiguous after OVERLAPPING/OVERLAPPABLE, pick this one anyway,
without complaint - silently, and possibly inconsistently across
modules compiled separately.
§ 06

Orphan instances

An orphan instance is one declared in a module that owns neither the class nor the type - here, a Pretty instance for Int, where this module defines Pretty but not Int, or defines Int but not Pretty. GHC warns because such an instance can only be found by whoever happens to import this exact module.

-- module Utils, which does not define Int or the standard Ord class
instance {-# OVERLAPPING #-} Ord [Char] where
compare = undefined -- illustrative only

The real-world version: a Pretty instance for a type from some other library, sitting in your own application code rather than in a module belonging to either side.

-- neither Pretty nor Data.Time.Day is defined in this module
import Data.Time (Day)
instance Pretty Day where
pretty = show

The warning, and why it matters: two separate modules could each define a conflicting orphan Pretty Day, and whichever one gets imported transitively into a given compilation unit silently wins - a real, hard-to-debug inconsistency across a large codebase.

warning: [-Worphans]
Orphan instance: instance Pretty Day

The fix, when you actually own the class: wrap the foreign type in a newtype and instance that instead - no orphan, because the newtype is defined in the same module as the instance.

newtype PrettyDay = PrettyDay Day
instance Pretty PrettyDay where
pretty (PrettyDay d) = show d
§ 07

GeneralizedNewtypeDeriving, safely

A newtype wrapping Int, deriving every numeric class Int already has, at zero runtime cost - this is the newtype deriving strategy the deriving-strategies post covers; shown here from the class-author's side of why it is sound.

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype UserId = UserId Int
deriving newtype (Eq, Ord, Num, Show)

Why it is safe for Eq/Ord/Num/Show but not for every class - it works because UserId and Int share a runtime representation, so the methods are the same code, coerced. A class whose methods mention the type in a position the role system will not allow to be coerced cannot be derived this way.

class Container f a | f -> a where
empty :: f a
newtype SafeList a = SafeList [a]
deriving newtype (Container [])
-- rejected: the class parameter f appears applied to a, not just
-- as a bare coercible type, so GND cannot derive across it safely
§ 08

Constraint kinds

ConstraintKinds lets a constraint itself be named and passed around as an ordinary type - useful the moment the same cluster of constraints appears on several signatures.

{-# LANGUAGE ConstraintKinds #-}
type Loggable a = (Show a, Describable a)
logIt :: Loggable a => a -> String
logIt x = show x <> ": " <> describe x

A constraint synonym parameterized over a monad, the shape mtl-style code actually uses to avoid repeating a long constraint list on every function in a module.

type AppM m = (Monad m, MonadLogger m String)
step1 :: AppM m => m ()
step1 = logMsg "step 1"
step2 :: AppM m => m ()
step2 = do
step1
logMsg "step 2"

A polymorphic Dict-style existential wrapping an arbitrary constraint, for passing a constraint as a first-class value rather than only as a context on a signature - constraints package library.

{-# LANGUAGE GADTs, KindSignatures #-}
import Data.Kind (Constraint)
data Dict :: Constraint -> * where
Dict :: c => Dict c
showDict :: Dict (Show Int)
showDict = Dict
useDict :: Dict (Show a) -> a -> String
useDict Dict x = show x
§ 09

Deriving via a typeclass's own default signature

DefaultSignatures lets a class state what its default method would look like if it were derived generically - the class-author counterpart to DeriveAnyClass on the caller's side.

{-# LANGUAGE DefaultSignatures, DeriveGeneric #-}
import GHC.Generics
class GPretty a where
gpretty :: a -> String
default gpretty :: (Generic a, GPretty' (Rep a)) => a -> String
gpretty = gpretty' . from
class GPretty' f where
gpretty' :: f p -> String

An instance can now opt into the generic default with an empty body, the same DeriveAnyClass shape covered on the lens/derive posts, but authored here from the class side rather than consumed from the caller side.

data Point = Point Int Int deriving (Generic)
instance GPretty Point

Superclasses, fundeps, and overlapping instances are all ways of telling GHC more about which instance to pick when more than one could apply; generic deriving is the other end of the same problem, generating the instance body once the class itself is settled. See Deriving Strategies in Haskell for what each deriving mode actually produces.