7 min read
On this page

Typeclasses

Typeclasses are Haskell's mechanism for ad-hoc polymorphism — letting the same function name work on different types in different ways. If you've used Java interfaces, Rust traits, Swift protocols, or Scala traits, the surface analogy is close. The deeper machinery is different, and treating typeclasses as Java interfaces will mislead you in subtle ways.

This page is about what typeclasses actually are, the ones you'll use most, how to define your own, and the subtle distinction between data, newtype, and type.

What a Typeclass Actually Is

A typeclass is a contract — a set of functions that any type implementing the class must provide. The class itself doesn't define data; it defines an interface. Implementations are called instances.

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool
    x /= y = not (x == y)   -- default implementation

instance Eq Bool where
    True  == True  = True
    False == False = True
    _     == _     = False

Eq declares that any type a that wants to be in Eq must provide ==. We get /= for free thanks to the default implementation. The instance for Bool defines how equality works for booleans.

Now consider where this differs from a Java interface. In Java, a class implements an interface at the class definition. The relationship is fixed when the class is written, and the dispatch happens through a virtual table on the object.

In Haskell, instances are separate from data definitions. You can write an instance for an existing type without modifying that type — including types from libraries you don't control. This is sometimes called the "expression problem" answer: Haskell lets you add new operations to existing types without recompiling them, and add new types to existing operations without modifying the operation. Java interfaces only support the second direction.

The dispatch mechanism is also different. Haskell typeclasses are resolved at compile time through type inference. There's no runtime lookup, no vtable — the compiler picks the right implementation based on the inferred types and inlines the call. This is why typeclass-heavy code in Haskell can be as fast as monomorphic code; there's nothing to dispatch at runtime.

The Common Typeclasses

A handful of typeclasses appear constantly. Knowing them well covers most everyday code.

Eq for equality. ==, /=. Almost every type derives this.

Ord for ordering. <, >, compare. Required for keys in sorted maps and sets, sorting lists, etc.

Show for converting to a String for display. Mostly used for debugging — for human-facing output, you typically write your own renderer.

Read for parsing back from a string. Generally not what you want for real input parsing; it's slow and the error messages are bad. Use Text.Read.readMaybe for safer parsing or a real parser library.

Num, Fractional, Floating, Integral — the numeric hierarchy. Num covers +, -, *. Fractional adds /. Floating adds sqrt, sin, etc. Integral covers div, mod.

Functor — types you can map over. fmap is its only required function. Lists, Maybe, Either e, IO — all functors.

Applicative — functors with apply. Adds pure and <*>. Used in parser combinators, validation libraries, and concurrent abstractions.

Monad — sequenced computation with context. Adds >>= (bind) and return. The reason every Haskell tutorial spends so much time on it. We'll cover it deeply later.

Foldable, Traversable — for container types. Lets you fold over a structure or traverse it with effects.

Semigroup, Monoid — types with a way to combine values. <> is the operator. Lists are a monoid (concatenation), strings are a monoid, Sum Int is a monoid (addition).

The hierarchy matters. Monad requires Applicative, which requires Functor. If you make something a Monad, you also need to provide Applicative and Functor instances.

Functor: A Closer Look

Functor is the gateway to the rest of the abstraction tower. It's also genuinely useful on its own.

class Functor f where
    fmap :: (a -> b) -> f a -> f b

f here is a type constructor — something that takes a type and produces a type, like Maybe or [] or IO. Functor is a typeclass over type constructors, not over types directly. This is sometimes called "higher-kinded polymorphism" and it's one of the things Haskell does that most languages can't.

instance Functor Maybe where
    fmap _ Nothing  = Nothing
    fmap f (Just x) = Just (f x)

instance Functor [] where
    fmap = map

Now fmap works on any functor:

ghci> fmap (+1) (Just 5)
Just 6
ghci> fmap (+1) [1, 2, 3]
[2, 3, 4]
ghci> fmap (+1) Nothing
Nothing

There's a synonym, <$>, that's used more commonly:

ghci> (+1) <$> Just 5
Just 6
ghci> show <$> [1, 2, 3]
["1", "2", "3"]

Functor obeys two laws (which the compiler doesn't check but every sensible instance follows):

  1. fmap id = id — mapping the identity does nothing.
  2. fmap (g . f) = fmap g . fmap f — composing maps is the same as mapping the composition.

Instances that violate these laws are buggy. The laws aren't decoration; library code assumes them.

Defining Your Own Typeclasses

Most of the time you'll consume typeclasses from the standard library, but defining your own becomes natural after a while. A common pattern: an interface for things that can be serialized or rendered.

class Renderable a where
    render :: a -> Text

instance Renderable User where
    render u = "<User " <> name u <> ">"

instance Renderable Order where
    render o = "<Order #" <> tshow (orderId o) <> ">"

When you find yourself writing the same function over and over with different types, that's a candidate for a typeclass. When you find yourself writing it twice, write the function. When you find yourself writing it ten times, consider abstracting.

A guideline that experienced Haskellers come back to: don't introduce a typeclass when a regular function would do. Typeclasses add complexity, and they only pay for themselves when you have multiple meaningful instances. A typeclass with one instance is just a worse function.

Deriving Show, Eq, and More

We saw deriving in the previous page. Worth noting that most of the standard typeclasses can be derived:

data Color = Red | Green | Blue
    deriving (Show, Eq, Ord, Enum, Bounded, Read)

ghci> [minBound .. maxBound] :: [Color]
[Red, Green, Blue]
ghci> Red < Blue
True
ghci> show Green
"Green"

For more complex derivations, GHC supports several strategies:

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DerivingVia #-}

newtype UserId = UserId Int
    deriving stock    (Show, Eq, Ord)        -- standard derivation
    deriving newtype  (Num)                  -- borrow Int's Num instance

deriving stock is the classical mechanism. deriving newtype lets a newtype borrow instances from its underlying type. deriving via lets you pick a different type whose instances you want to use. These are advanced but show up in real code.

type, newtype, and data

Three keywords that introduce new names — they look similar and behave very differently.

type is a synonym. It introduces a new name for an existing type, with no runtime distinction.

type Name = String
type Age  = Int

greet :: Name -> Age -> String
greet n a = "Hi " ++ n ++ ", you're " ++ show a

Name and String are interchangeable everywhere. The compiler treats them as the same type. This is fine for documentation but it doesn't prevent bugs — you can pass an arbitrary String where a Name is expected, because they're the same type.

newtype wraps a single field in a new type. At runtime there's no overhead; the compiler erases the wrapper. But the type system treats them as distinct.

newtype Name = Name String
newtype Age  = Age Int

greet :: Name -> Age -> String
greet (Name n) (Age a) = "Hi " ++ n ++ ", you're " ++ show a

-- this is now a compile error:
-- greet "Ada" 30      <- both arguments have wrong type
-- you must wrap them:
greetAda = greet (Name "Ada") (Age 30)

This is the right tool when you want to prevent confusion between two values of the same underlying type. UserId and OrderId are both ints, but you should not be able to pass one where the other is expected. newtype enforces that for free.

data is the general form. Multiple constructors, multiple fields, all the algebraic data type machinery from the previous page.

A useful rule:

  • type for documentation when you don't need protection.
  • newtype when you want a distinct type around a single value with no runtime cost.
  • data for everything else.

Newtype for Different Behavior

A common newtype pattern: wrap a type to give it a different typeclass instance. The classic example is the Sum and Product newtypes for monoids:

newtype Sum a     = Sum     { getSum     :: a }
newtype Product a = Product { getProduct :: a }

instance Num a => Semigroup (Sum a) where
    Sum x <> Sum y = Sum (x + y)

instance Num a => Semigroup (Product a) where
    Product x <> Product y = Product (x * y)

Int itself can't be a monoid because it has two reasonable choices — addition or multiplication. Wrapping it in Sum or Product lets you pick which one. Now foldMap Sum [1,2,3] gives Sum 6 and foldMap Product [1,2,3] gives Product 6. Same data, different operation, picked by the type.

This pattern shows up everywhere in idiomatic Haskell. You'll see Min, Max, All, Any, First, Last, all wrapping basic types to give them specific monoid behavior.

Common Pitfalls

Treating typeclasses like Java interfaces. They are not. Don't try to model OO inheritance with them. Use composition.

Reaching for typeclasses too early. A function that takes a record is often clearer than a typeclass with one instance. Add a class only when you have two or more meaningful implementations.

Forgetting the laws. Functor, Monad, Monoid all have laws. Instances that break them work in isolation and break in surprising ways when composed with library code.

Confusing type and newtype. type Email = String doesn't prevent passing any string where an email is expected. newtype Email = Email String does. Use newtype when you want the distinction.

Orphan instances. An "orphan instance" is one defined neither in the module that defines the class nor in the module that defines the type. They cause inconsistency — different modules can see different instances. GHC warns about them; take the warnings seriously.

Key Takeaways

Typeclasses are interfaces, but they're resolved at compile time, can be added to existing types from libraries, and support higher-kinded abstractions like Functor. The standard hierarchy — Eq, Ord, Show, Functor, Applicative, Monad, Monoid — covers most common needs and most types you'll write should derive Show and Eq at minimum. Use data for general algebraic types, newtype for zero-cost wrappers that introduce type distinctions, and type only for synonyms. Define your own typeclasses sparingly — only when multiple meaningful instances exist.