Possibility as Container
Imagine a box that might be empty. You can't see inside without opening it. When you open it, you find either nothing---or something valuable.
This simple image captures one of programming's most elegant abstractions: types that represent possibility. Not the thing itself, but the potential presence or absence of the thing.
Remember our partial functions from Chapter~\ref{ch:rules-of-the-game}? Rules created "holes"---inputs that had no valid output. We asked: what should happen when we hit a hole?
The elegant answer is to make the function total. Instead of having holes, we fill them with explicit "no result" values. The function always returns something---it just tells us whether that something is a real answer or an acknowledgment of failure.
Maybe: Presence or Absence
The Maybe type (also called Option or Optional) represents a value that might not exist:
Maybe A:
Nothing OR
Just(value: A)Read this as: "A Maybe A is either Nothing, or it's Just some value of type A."
Maybe Number:
Nothing OR Just(42) OR Just(0) OR Just(-7) ...
Maybe Piece:
Nothing OR Just(white king) OR Just(black pawn) ... Every function that might not find what it's looking for can return Maybe:
find_piece_at(board, square): Maybe Piece
find_king(board, color): Maybe Square
parse_number(text): Maybe NumberResult: Success or Failure
Sometimes we don't just want to know if something exists---we want to know why it failed:
Result A E:
Success(value: A) OR
Failure(error: E)Read this as: "A Result A E is either a Success containing an A, or a Failure containing an E."
validate_move(state, move): Result ChessState MoveError
MoveError:
NoPieceAtSource OR
WrongColorPiece OR
IllegalMovement OR
WouldLeaveKingInCheckNow callers know exactly what might go wrong:
match validate_move(state, attempted):
case Success(new_state):
"Move applied!"
case Failure(NoPieceAtSource):
"There's no piece on that square"
case Failure(WrongColorPiece):
"That's not your piece"
case Failure(WouldLeaveKingInCheck):
"That move would leave your king in check"
...Working with Containers
These container types are powerful, but they create a problem. If we have a Maybe Piece, how do we work with the piece inside?
Suppose we want the color of a piece---but we only have a Maybe Piece:
maybe_piece: Maybe Piece = find_piece_at(board, e4)
// We want the color, but we have Maybe Piece, not Piece
// Naive approach:
match maybe_piece:
case Nothing:
Nothing
case Just(piece):
Just(piece.color)This works, but it's tedious. We're repeating the same pattern: "if Nothing, return Nothing; if Just, transform the contents."
Map: Transforming Contents
We can abstract this pattern into a function called map:
map(container, transform):
match container:
case Nothing:
return Nothing
case Just(value):
return Just(transform(value)) Visually, map is a branching flow---two rails, one for each possibility:
Now our code becomes:
maybe_piece = find_piece_at(board, e4)
maybe_color = map(maybe_piece, (p) => p.color)Read this procedurally: "Take the maybe-piece, and if there's a piece inside, get its color."
We can also read this declaratively, through substitution:
maybe_color is:
- If
maybe_pieceisJust(piece), thenJust(piece.color) - If
maybe_pieceisNothing, thenNothing
And since maybe_piece is find_piece_at(board, e4), this reads as:
"maybe_color is the color of the piece at e4, if one exists---otherwise nothing."
Chaining Operations
What if our transformation itself returns a Maybe? Suppose we want to find the king, then find its square, then find what's attacking it:
find_king(board, white): Maybe Square
find_attacker(board, square): Maybe Piece If we use map:
maybe_square = find_king(board, white)
maybe_maybe_attacker = map(maybe_square, (sq) => find_attacker(board, sq))
// Oops! We get Maybe (Maybe Piece) We need a way to "flatten" nested containers. This is called flatMap (or bind or chain):
flatMap(container, transform):
match container:
case Nothing:
return Nothing
case Just(value):
return transform(value) // transform returns MaybeNow:
maybe_square = find_king(board, white)
maybe_attacker = flatMap(maybe_square, (sq) => find_attacker(board, sq))
// Clean: Maybe PieceWe can chain multiple operations:
result = flatMap(find_king(board, white), (king_square) =>
flatMap(find_attacker(board, king_square), (attacker) =>
Just(attacker.kind)))If any step returns Nothing, the whole chain returns Nothing. Failure propagates automatically---no nested if-statements, no forgotten checks.
The Functor Pattern
The map operation isn't specific to Maybe. It works for any container:
// map over List
map([1, 2, 3], (x) => x * 2) // [2, 4, 6]
// map over Maybe
map(Just(5), (x) => x * 2) // Just(10)
map(Nothing, (x) => x * 2) // Nothing
// map over Result
map(Success(5), (x) => x * 2) // Success(10)
map(Failure(err), (x) => x * 2) // Failure(err) Any type that supports map is called a functor. Functors are containers we can "see inside" and transform.
The functor laws ensure map behaves sensibly:
- Mapping the identity function changes nothing:
map(x, id) == x - Mapping composed functions is the same as composing maps:
map(x, f∘g) == map(map(x, g), f)
The Power of Containers
These container types---Maybe, Result, and their operations map/flatMap---give us:
// A pipeline of operations, any of which might fail:
result =
parse_move(input)
|> flatMap((move) => validate_move(state, move))
|> map((new_state) => new_state.turn)
// result is Result Color MoveError
// All error cases handled, compiler verified- Explicit types: The signature tells us what might go wrong
- Compiler checking: We can't forget to handle failure
- Composability: Operations chain cleanly
- No effects: Failure is a value, not an interruption
Try It Yourself
Try It Yourself
6 exercises to practice
We've learned to encode possibility in types, making failure explicit and composable. But so far, our systems exist in a single moment. What about systems that remember? That can undo? That travel through time?
In the next part, we explore time, memory, and the persistence of state.