1

Algebraic Variants

Why this matters

A journey can contain travel, fights, and healing. Each event carries a number, but that number means something different for each event. An algebraic data type keeps the meaning attached to the value.

🎯 You will learn to

  • Apply constructors to distinguish variants of one type.
  • Analyze each variant with a case expression.

One type, several possibilities

data Event = Travel Integer | Fight Integer | Heal Integer says that an Event is one of three alternatives. Fight 7 is a value of type Event. Fight is its constructor, with type Integer -> Event.

case event of inspects the constructor. In Fight amount -> ..., the pattern binds amount to the stored integer. Each branch returns a value of the same result type. These patterns work just like the list patterns from Foundations, with names chosen for our domain.

deriving (Eq, Show) asks Haskell to supply structural equality and a readable representation. It lets the tests compare events and lets print display them. Constructors begin with an uppercase letter; ordinary bindings begin with a lowercase letter.

Thinking time!

Look at the starter before running it. Will its tuple contain (2,-7,3), (10,7,3), or a type error? Does naming a constructor Fight automatically subtract anything?

Commit to a prediction, then use Run. Explain which expression in each branch determines the result.

Your turn

Repair eventDelta so it returns the change in health for one event:

  • Travel distance restores one point per four whole distance units: use integer division, rounding down.
  • Fight damage loses damage points, so the change is negative.
  • Heal amount restores amount points.

All event numbers are nonnegative integers. This function reports a change; it does not yet impose a health cap or process a sequence. Change the sample to include a travel distance below four and predict that result before running again.

Starter files
Main.hs
module Main where

data Event = Travel Integer | Fight Integer | Heal Integer
  deriving (Eq, Show)

eventDelta :: Event -> Integer
eventDelta event = case event of
  Travel distance -> distance
  Fight damage -> damage
  Heal amount -> amount

main :: IO ()
main = print (eventDelta (Travel 10), eventDelta (Fight 7), eventDelta (Heal 3))
2

Records and New Values

Why this matters

A tuple can hold a hero’s name and health, but remembering which position means what gets old quickly. A record names those fields. Updating a record produces another value, so the original remains available for a before-and-after comparison.

🎯 You will learn to

  • Apply record construction, field access, and record updates.
  • Analyze which fields a transformation should preserve.

Named fields

Hero {heroName :: String, health :: Integer} defines the constructor and two field-access functions. For a hero h, health h reads its health. Both Hero "Fern" 70 and Hero {heroName = "Fern", health = 70} construct the same value.

The supplied renameHero is a worked example. Its expression hero {heroName = newName} produces a new record with a different name and the old health. It does not mutate hero. The braces describe the fields that change; omitted fields retain their values.

Thinking time!

The starter attempts to heal Fern by 15 points. Will it print health values (85,70), (15,70), or (15,15)? Read the update expression, commit to an answer, then run it.

Your turn

Repair healHero amount hero to add the healing amount to the existing health, stopping at 100. Preserve the name. The original hero must still represent its original values.

You can express the cap with a guard or with min x y, which returns the smaller of two ordered values.

Assume the original health is between 0 and 100, inclusive, and the amount is nonnegative. Try an ordinary heal, an amount that reaches exactly 100, and an amount that would exceed 100. Explain why using the same hero as the input twice does not apply two cumulative heals.

Starter files
Main.hs
module Main where

data Hero = Hero {heroName :: String, health :: Integer}
  deriving (Eq, Show)

renameHero :: String -> Hero -> Hero
renameHero newName hero = hero {heroName = newName}

healHero :: Integer -> Hero -> Hero
healHero amount hero = hero {health = amount}

main :: IO ()
main = let before = Hero "Fern" 70
           after = healHero 15 before
       in print (health after, health before)
3

Generic Values and Constraints

Why this matters

A reward may be absent or present. Its payload might be a number or a name. A type parameter lets us reuse that structure while still asking the compiler to check which operations make sense for the payload.

🎯 You will learn to

  • Analyze the relationship between a generic container and its payload type.
  • Apply an Ord constraint when a function compares payloads.

A type with a parameter

data Prize a = NoPrize | Prize a describes a family of types. Prize 7 can be a Prize Integer; Prize "Crown" is a Prize String. Within one comparison, both inputs and the result must use the same payload type.

In bestPrize :: Ord a => Prize a -> Prize a -> Prize a, Ord a => says that payloads support ordering. It is needed because the function compares payloads. A function that merely wraps or unwraps a value does not need ordering.

Eq supports equality, Ord supports ordering, and Show supports text representations. These are type classes. A constraint states a requirement; it does not convert one type into another. Numeric operations have requirements too: Num supports arithmetic such as addition, while / requires Fractional.

Thinking time!

The starter always keeps a present first prize. Predict both printed results before running it. Will the comparison of strings use alphabetical order or their lengths?

Haskell orders strings lexicographically, comparing characters from the start. For example, "z" > "alpha" is true despite "z" being shorter.

Your turn

Repair bestPrize to choose the larger present payload. A missing prize loses to any present prize; two missing prizes produce NoPrize. Equal payloads may return either equivalent value.

As a counterpart to min, max x y returns the larger of two ordered values. A guard can express the same choice.

Keep the generic signature. The same function must work for integers and strings. Write a one-sentence explanation of why changing the signature to Prize Integer -> Prize Integer -> Prize Integer would narrow the contract even if the first sample still worked.

Starter files
Main.hs
module Main where

data Prize a = NoPrize | Prize a deriving (Eq, Show)

bestPrize :: Ord a => Prize a -> Prize a -> Prize a
bestPrize NoPrize other = other
bestPrize first NoPrize = first
bestPrize (Prize x) (Prize y) = Prize x

main :: IO ()
main = do
  print (bestPrize (Prize 4) (Prize 12) :: Prize Integer)
  print (bestPrize (Prize "alpha") (Prize "z"))
4

Recursive Data Types

Why this matters

A trail is either finished or has one stop followed by the rest of the trail. That sentence is already a data definition. Recursive types let the structure of the data tell us the structure of its functions.

🎯 You will learn to

  • Apply base and recursive cases to a user-defined list.
  • Create a conversion that preserves values, types, and order.

A trail inside a trail

data Trail a = End | Stop a (Trail a) has a nonrecursive base constructor and a recursive constructor. Stop "Gate" (Stop "Lake" End) is a Trail String with two stops.

Read the supplied toList as a worked example. End becomes the empty built-in list. A Stop contributes its value and recursively converts the rest. Its pattern has exactly the fields declared by the constructor.

Thinking time!

Predict toList (Stop "Gate" (Stop "Lake" End)). Then predict the starter’s round trip toList (fromList ["Gate","Lake"]). Will the two results agree? Run and explain the difference.

Your turn

Implement fromList :: [a] -> Trail a. It must produce one stop for each input value in the original order and finish with End. An empty input produces End.

Keep toList as the supplied observation function. The exercise is to derive the conversion in the other direction. Test both numbers and strings, and write down how the empty-list pattern corresponds to a constructor with no remaining stops.

Starter files
Main.hs
module Main where

data Trail a = End | Stop a (Trail a) deriving (Eq, Show)

toList :: Trail a -> [a]
toList End = []
toList (Stop x rest) = x : toList rest

fromList :: [a] -> Trail a
fromList xs = End

main :: IO ()
main = do
  print (toList (Stop "Gate" (Stop "Lake" End)))
  print (toList (fromList ["Gate", "Lake"]))
5

Persistent Trail Updates

Why this matters

The party changes its plan, but yesterday’s route must stay available. A persistent update returns a new trail while preserving the old value. We can reuse the part after the removed stop because none of its links need to change.

🎯 You will learn to

  • Create a recursive deletion that preserves the remaining order.
  • Evaluate which part of an immutable trail needs reconstruction.

An update with a boundary

Stops use zero-based indices. Removing index 1 from ["Gate","Lake","Tower"] leaves ["Gate","Tower"]. At the selected stop, its remaining trail is already the result we want. Before that stop, each retained stop needs a link to the updated remainder.

Use this source-level model when reasoning about cost: count a new Stop for each retained prefix stop reconstructed by a direct recursive algorithm. Removing the first stop can return the existing tail. Removing index 2 rebuilds the two stops before it and can share everything after it. Actual compiler allocations are an implementation detail; our output tests do not measure them.

Thinking time!

Predict both lists in the starter’s printed tuple. Does calling removeStop alter original even if the return value is discarded? Run it, then connect your answer to the record update from step 2.

Your turn

Implement removeStop :: Int -> Trail a -> Trail a:

  • Remove exactly the stop at the requested zero-based index.
  • Preserve every other value in its original order.
  • For a negative index or an index outside the trail, return an equal, unchanged trail.

The conversions are supplied for inspecting results. Aim to process End and Stop directly so you can explain where sharing is possible. Equivalent results pass the checks; inspect your own definition to judge the reconstruction cost.

Starter files
Main.hs
module Main where

data Trail a = End | Stop a (Trail a) deriving (Eq, Show)

fromList :: [a] -> Trail a
fromList = foldr Stop End

toList :: Trail a -> [a]
toList End = []
toList (Stop x rest) = x : toList rest

removeStop :: Int -> Trail a -> Trail a
removeStop index trail = trail

main :: IO ()
main = let original = fromList ["Gate", "Lake", "Tower"]
       in print (toList (removeStop 1 original), toList original)
6

Branching Data

Why this matters

A route can branch into several routes. That changes the recursive question: we need a result from every child, then we combine those results. The same pattern powers document searches and nested menus.

🎯 You will learn to

  • Analyze the difference between a node’s payload and its children.
  • Combine recursive results across an arbitrary number of children.

Two lists with different jobs

Route [a] [Route a] stores a list of labels at this node and a list of child routes. NoRoute represents no node. A Route [] children is still a node, and its children may contain labels even though it has none.

We want to count labeled nodes, not labels. A node with three labels contributes one. A node with no labels contributes zero, but its children must still be visited. Assume the input is a finite tree without cycles.

Thinking time!

The sample root has two labels and three child entries, one of which is NoRoute. One child has no labels but leads to a labeled grandchild. Predict the total number of labeled nodes, then predict the starter’s output. Run to distinguish its local count from the required whole-tree count.

Your turn

Repair countLabeled :: Route a -> Int. Count every node whose own labels list is nonempty. Do not count NoRoute, and do not stop traversing a real node just because its labels list is empty.

The current-node contribution is already supplied. Decide how to turn the child list into a single additional count. You may use a fold, map plus sum, or an equivalent recursive helper. Before testing, draw up two tiny examples that distinguish “number of labels” from “number of labeled nodes.”

Starter files
Main.hs
module Main where

data Route a = NoRoute | Route [a] [Route a] deriving (Eq, Show)

countLabeled :: Route a -> Int
countLabeled NoRoute = 0
countLabeled (Route labels children) =
  let own = if null labels then 0 else 1
  in own

sample :: Route String
sample = Route ["Gate", "Start"]
  [Route [] [Route ["Lake"] []], NoRoute, Route ["Tower"] []]

main :: IO ()
main = print (countLabeled sample)
7

Persistent Search Trees

Why this matters

Keeping old versions need not mean copying an entire tree. In a binary search tree, insertion follows one search path. Rebuilding that path can preserve every unaffected branch.

🎯 You will learn to

  • Apply ordered comparisons to choose a recursive branch.
  • Analyze which subtrees an insertion must preserve.

The ordering contract

Tip is an empty tree. Fork value left right is a node. Every value in left is smaller than value, and every value in right is larger. This exercise represents a set: inserting an existing value must not add a duplicate. Assume the input already obeys this rule and has no cycles.

The provided toAscList visits left subtree, current value, then right subtree. For a valid search tree this produces ascending order. It is an observation helper, not a request to convert the tree to a list to perform insertion.

Thinking time!

The starter inserts 5 below a root of 8 by replacing the entire left subtree. Predict which old value disappears. Run, then explain why a correct comparison alone is not enough to preserve a data structure.

Your turn

Repair insertKey :: Ord a => a -> SearchTree a -> SearchTree a to add a value while preserving every old value and the ordering rule. An empty tree becomes a singleton. Inserting an existing value leaves the same set of keys. Revisit both smaller and larger insertions.

Aim to return the untouched child directly and recursively update only the chosen child. The tests inspect the set of values and order; your explanation should identify the possible sharing. A direct insertion visits a path of height h and creates at most h + 1 nodes. A balanced tree has logarithmic height; a chain can have linear height. Direct insertion does not rebalance the tree.

Starter files
Main.hs
module Main where

data SearchTree a = Tip | Fork a (SearchTree a) (SearchTree a)
  deriving (Eq, Show)

toAscList :: SearchTree a -> [a]
toAscList Tip = []
toAscList (Fork x left right) = toAscList left ++ [x] ++ toAscList right

insertKey :: Ord a => a -> SearchTree a -> SearchTree a
insertKey value Tip = Fork value Tip Tip
insertKey value (Fork x left right)
  | value < x = Fork x (Fork value Tip Tip) right
  | value > x = Fork x left (Fork value Tip Tip)
  | otherwise = Fork x left right

sample :: SearchTree Integer
sample = Fork 8 (Fork 3 Tip Tip) (Fork 12 Tip Tip)

main :: IO ()
main = print (toAscList (insertKey 5 sample), toAscList sample)
8

Expression Trees

Why this matters

The course’s language tools manipulate programs as data. Once an expression is a tree, evaluating it becomes another recursive transformation. The tree structure records grouping before evaluation even starts.

🎯 You will learn to

  • Analyze how constructors represent expression grouping.
  • Create an evaluator from the meaning of each constructor.

Data that describes a computation

Number 3 describes the number three. Plus left right describes addition of two expressions, while Times left right describes multiplication. For example, Times (Plus (Number 2) (Number 5)) (Number 4) represents (2 + 5) * 4.

Constructing a Plus value does not itself perform addition. It produces data containing two child expressions. eval is the function that assigns that data its numerical meaning. As with the branching routes, assume finite trees without cycles.

Thinking time!

Before running, predict the correct value of the sample. Now predict what the starter actually prints: it currently follows only the left child of each compound expression. Which part of the expression disappears from that behavior?

Your turn

Implement eval :: Expr -> Integer for every finite expression built from Number, Plus, and Times. A number evaluates to its payload. A compound expression evaluates its child expressions and combines their results using its stated operation.

Keep the constructors as supplied; choose your own helper structure. Test a number alone, one operation, and nested operations with different grouping. Then explain why adding special cases for the displayed sample would miss the purpose of a recursive evaluator.

Starter files
Main.hs
module Main where

data Expr = Number Integer | Plus Expr Expr | Times Expr Expr
  deriving (Eq, Show)

eval :: Expr -> Integer
eval (Number n) = n
eval (Plus left right) = eval left
eval (Times left right) = eval left

sample :: Expr
sample = Times (Plus (Number 2) (Number 5)) (Number 4)

main :: IO ()
main = print (eval sample)
9

An Expedition Simulator

Why this matters

A game can change over time without mutating a variable. A pure simulator receives the events, carries the current state through them, and returns the result. This is where the separate skills become one program.

🎯 You will learn to

  • Create a pure simulator from a complete behavioral contract.
  • Evaluate boundary cases before trusting a successful sample run.

The expedition contract

Implement journey :: [Event] -> Integer. The explorer starts with 100 hit points and cannot exceed 100. Every event carries a nonnegative integer. Process events in list order:

  • With more than 40 hit points, Fight damage subtracts the full damage and Travel distance restores distance `div` 4 points.
  • With 40 or fewer hit points, a fight subtracts damage `div` 2 points and travel restores nothing.
  • Heal amount restores that amount in either mode, subject to the 100-point cap.
  • Determine the mode from the health before the event. The resulting health determines the mode for the next event.
  • If health becomes zero or negative, the journey ends immediately with -1. Later healing cannot revive the explorer.
  • If no events remain, return the remaining health. An empty journey returns 100.

A trace before a program

On paper, trace [Fight 60, Travel 40, Heal 1, Fight 3]. Record the health before each event, the mode for that event, and the resulting health. Commit to your final answer before using Run. The starter is a runnable placeholder that currently ignores the events; its output is evidence about the placeholder, not the game’s rules.

Your turn

Write the simulator using the supplied type and entry function. Choose your helpers and recursion or folding strategy. A helper can receive the health that the public function’s signature intentionally hides. Preserve main as a sample driver, or change its inputs while experimenting.

Plan examples that distinguish the boundaries: health 40 versus 41, zero versus one remaining point, and healing exactly to versus beyond the cap. After your tests pass, explain why summing eventDelta values from step 1 cannot implement this contract: the effect of a fight or travel depends on the state reached so far.

A return visit

On another day, hide the solution and recreate one small function from this path: a list conversion, a tree traversal, or this simulator. Change the sample data and predict the result before running. That retrieval gives a better check of what stuck than rereading the completed solution.

Starter files
Main.hs
module Main where

data Event = Travel Integer | Fight Integer | Heal Integer
  deriving (Eq, Show)

journey :: [Event] -> Integer
journey events = 100

main :: IO ()
main = print (journey [Fight 60, Travel 40, Heal 1, Fight 3])