Haskell's type system isn't just generics — it can carry real facts and rule out whole classes of bug at compile time. This last chapter samples that power, then maps where to go after the series.
You've spent nine chapters learning that Haskell pushes work into the type system: errors become Either, effects become IO, absence becomes Maybe. This chapter is about the next step — making the type system itself do more, so that whole categories of bug simply can't compile.
None of this is exotic. It's the same instinct you already have when you reach for an opaque type in Scala 3 to stop a raw String from masquerading as a validated one. Haskell just gives you sharper tools for the same goal. We'll sample the headline ones, then close the series:
In Scala, generics top out around List[A] — types parameterised by other types. Useful, but the type system is mostly a filing system: it tracks what shape things are, and that's it.
Haskell lets the type system carry facts. Not just "this is a list," but "this is a list of length 3," or "this expression evaluates to an Int, never a Bool," or "this Double is in dollars and must never be added to euros." Once a fact lives in the type, the compiler enforces it for free — and the error shows up at compile time, not in production.
You already know the slogan: make illegal states unrepresentable. In Scala that's mostly a design discipline — pick your case classes so bad combinations can't be constructed. In Haskell it becomes literal: you encode the constraint in the type, and the type checker is your proof that no illegal state exists. The rest of this chapter is four concrete ways to do that.
Recall ordinary data from Chapter 3: every constructor of data Expr a produces the same Expr a, whatever a happens to be. That's too loose if you're building a typed mini-language — nothing stops you from adding a number to a boolean.
A GADT (generalised algebraic data type) fixes that by letting each constructor declare its own precise result type. Turn on the GADTs extension and write the constructors with explicit signatures:
Read it line by line: IntLit builds an Expr Int, BoolLit builds an Expr Bool, and crucially Add only accepts two Expr Ints. There is no way to write Add on a boolean — it doesn't type-check. The evaluator can't go wrong, because the types it has to handle have already been narrowed.
If you've used Scala 3's enum Expr[A] with per-case extends Expr[Int], this is exactly the same idea — Scala calls them GADT-style enums. Haskell's version is just the original, and the syntax is closer to "here's each constructor's type signature." Build an expression below and watch valid ones evaluate while type-invalid ones get rejected.
Here's a bug every codebase has shipped: a Double in dollars added to a Double in euros, or meters mixed with feet. Both are Double, so the compiler is happy and the rocket lands in the sea.
A newtype wraps a value with a distinct type but zero runtime overhead — at runtime it is the underlying value, the wrapper exists only for the type checker:
The tag parameter is a phantom type: it appears on the left of the = but in no constructor on the right. It carries no runtime value — it's pure compile-time labelling. So Tagged "USD" Double and Tagged "EUR" Double are different types that hold the same bytes. Try to add a USD to a EUR and it won't compile; add two USDs and you're fine.
This is Scala 3's opaque type or the older tagged-type trick — a nominal wrapper the compiler distinguishes but the runtime forgets. Zero-overhead safety: you pay nothing and you can never mix the tags. Try it below.
You write functions on values every day: give them a value, they compute a value. A type family is the same idea one level up — give it a type, it computes a type. It's a function whose inputs and outputs are types.
A quick taste. Suppose every container type has an "element type." A type family lets you write that mapping down:
Here Elem is a function from a type to a type: feed it [Int] and it computes Int; feed it Text and it computes Char. Now a generic function can talk about "the element type of c" without knowing which container c is.
If you've used Scala's type members — trait Coll { type Elem } and c.Elem — that's the same move: a type that depends on another type. Haskell calls the per-instance version associated types, declared right inside a typeclass. You won't reach for these on day one, but they're how the heavyweight libraries stay both generic and precise. We'll leave it at the taste.
If values have types, what do types have? Kinds. A kind is the "type of a type," and it answers one question: how many type arguments does this thing still need before it's a real, value-holding type?
Int :: * — a complete type, kind star. It needs no arguments; you can have a value of type Int right now.Maybe :: * -> * — not yet a type. Hand it one type (Maybe Int) and it becomes one. It's a type constructor.Either :: * -> * -> * — needs two type arguments before it's complete.This is why Functor in Chapter 5 was fussy about what you give it. Functor f requires f :: * -> * — a type with exactly one hole, like Maybe or [] or IO. You can't make Int a Functor (kind *, no hole) and you can't directly make Either one (kind * -> * -> *, two holes) — the kinds don't fit. In Scala this is the higher-kinded F[_] versus F[_, _] distinction; kinds make it explicit and check it.
And one sentence on going further: the DataKinds extension promotes ordinary values like True and numbers up into the kind level, so you can index a type by an actual number — that's how length-checked vectors like Vec 3 Int become possible.
:kind Maybe prints Maybe :: * -> * the same way :type prints a value's type. Same tool, one level up.Theory aside, here's the practical landscape — the Haskell equivalents of sbt, Maven Central, and the libraries you import without thinking.
text / bytestring — efficient strings. Use Text over the default String (a linked list of chars) in real code.containers — Map, Set, Seq. Your scala.collection immutable workhorses.aeson — JSON encode/decode, the circe/play-json of Haskell.mtl — the monad-transformer classes from Chapter 9, in their standard packaging.lens / optics — composable getters/setters for deep immutable updates; like monocle in Scala.servant — describe a web API as a type, get the server and client for free. A very Haskell idea, and a great showcase of this chapter.GHC ships behind {-# LANGUAGE … #-} pragmas. The friendly starter set: OverloadedStrings (string literals become Text/etc.), RecordWildCards and NamedFieldPuns (less record boilerplate), LambdaCase, DeriveFunctor, and the ones from this chapter — GADTs and DataKinds — when you need them.
You've got the language. The fastest way to make it stick is to build something and read a book alongside it.
lens/optics to click.Ten chapters, each anchored to something you already do in Scala. Click any node to recall what it gave you and its Scala bridge — then go finish the ones you skipped.
The GADT-style typed expression, side by side. Scala 3's per-case extends Expr[Int] and Haskell's per-constructor signatures are the same move — narrow each constructor's result type so the evaluator is total and safe.
You started by unlearning statements and mutation, and you're ending with a type system that can prove your code right. Everything in between — currying, data, typeclasses, functor/applicative/monad, IO, laziness, transformers — was building toward the same payoff: programs you can reason about, and a compiler that catches the rest. You can read and write real Haskell now. Go build something.