No history yet

Algebraic Data Types

An Algebra of Types

In mathematics, an algebra is a set equipped with operations. Haskell's type system can be viewed in the same way. The "set" is the collection of all possible types, and the operations are ways to combine them to create new ones.

Two fundamental operations give these structures their name: addition (sum types) and multiplication (product types). Just as you can combine vector spaces, you can combine types to precisely model the data your program needs.

Product Types

A product type groups multiple types into one. It's the programming equivalent of a Cartesian product, representing a logical AND. A value of a product type must contain a value for every component type.

Here’s how you can define a type to represent a 2D point, which needs both an x and a y coordinate.

data Point = Point Int Int

The data keyword introduces a new type. Point is the name of our new type. The second Point is a data constructor, which is a function that creates values of type Point. It takes two Int values and bundles them together.

While this works, accessing the individual coordinates is clumsy. For a more descriptive structure, we can use record syntax.

data Point = Point { x :: Int, y :: Int } deriving (Show)

This version is equivalent but also gives us named accessor functions. Now, if p is a Point, x p will give us its x-coordinate. The deriving (Show) part is a bit of magic that tells Haskell how to convert a Point to a string for printing.

Sum Types

A sum type defines a choice between different kinds of values. It represents a logical OR, analogous to a disjoint union in set theory. A value of a sum type can only be one of the specified options.

For example, let's model a geometric shape that could be either a circle or a rectangle.

data Shape =
    Circle Float          -- A circle is defined by its radius
  | Rectangle Float Float -- A rectangle by its width and height

Here, Shape is the type. It has two data constructors, Circle and Rectangle. The pipe | reads as "or". So a Shape is either a Circle with a Float radius, or a Rectangle with a Float width and a Float height. Notice how the Rectangle constructor itself uses a product type to hold two floats.

Working with ADTs

Once you have these custom types, how do you use them? You can't just perform arithmetic on a Shape. You need a way to inspect a value and behave differently depending on which constructor was used. This is done with pattern matching.

Pattern matching is like defining a function over different cases or subspaces. It lets you deconstruct a value right in the function definition. Let's write a function to calculate the area of a Shape.

area :: Shape -> Float
area (Circle r) = pi * r ^ 2
area (Rectangle w h) = w * h

The function area has two clauses. When you call area with a Shape, Haskell checks which pattern it matches.

If the Shape is a Circle, it matches the first clause, (Circle r). This binds the circle's radius to the variable r, which is then used in the expression pi * r ^ 2. If it's a Rectangle, it matches the second clause, (Rectangle w h), binding the width and height to w and h respectively.

The compiler enforces completeness. If you add a new constructor to Shape (like Triangle) but forget to update the area function, your code won't compile. This prevents a huge class of runtime errors.

Pattern matching allows the structure of the data to guide the structure of the code.

Sometimes, you need to add extra conditions to a pattern. These are called guards.

describe :: Shape -> String
describe (Circle r)
  | r > 10.0  = "This is a large circle."
  | otherwise = "This is a small circle."
describe (Rectangle _ _) = "This is a rectangle."

Guards are boolean expressions following a pattern, introduced by a |. They allow for more refined logic within a single pattern match. The otherwise guard always evaluates to true, acting as a catch-all for that pattern.

Generic Types

So far, our types have been concrete. But what if you want to define a data structure that can hold any type, like a container? This is achieved with parametric polymorphism, using type variables.

A classic example is handling a value that might be missing. Instead of using null, which causes errors, we can build a type that explicitly models this possibility.

data Maybe a = Nothing | Just a

Here, a is a type variable. It's a placeholder for any concrete type. The Maybe a type can hold a value of any type a. It is either Nothing (representing the absence of a value) or Just a (representing the presence of a value).

You could have a Maybe Int, a Maybe String, or even a Maybe Shape. The type variable a allows the Maybe type to be a generic container.

Another common sum type is Either, used to handle operations that can return one of two types, often a successful result or an error message.

data Either a b = Left a | Right b

By convention, Left is used to hold an error value (like a String describing what went wrong), and Right is used to hold a successful result. Using Either String Int for a function that parses a number from text is far safer than throwing an exception.

These types, combined with pattern matching, create a powerful system for writing code that is not only correct but also easy to reason about and maintain.

Quiz Questions 1/6

In the context of Haskell's algebraic data types, what does a product type represent?

Quiz Questions 2/6

Consider the following Haskell type definition:

data Status = Idle | Loading String | Error String Int

How would you use pattern matching to define a function isError that returns True only if the Status is Error?