Haskell 1: Expressions, Types, and Recursion
For CS131 students who know functions, conditionals, and lists in Python or C++: build pure Haskell functions through prediction, debugging, and independent practice. Allow 90–110 minutes, with a break after Step 5.
Functions as Expressions
Why this matters
Haskell asks a different question: what value does this expression describe? Start with one small function, and you can explore that shift without installing anything.
🎯 You will learn to
- Analyze how function calls group with arithmetic
- Modify a pure function while keeping output in
main
This is Part 1 of a three-part path. You need functions, conditionals, and lists from Python or C++; you do not need any Haskell. Plan for 90–110 minutes, and take a break after Step 5.
A tiny worked example
square :: Int -> Int
square x = x * x
:: introduces a type signature: an Int goes in and an Int comes
out. = defines the result; it does not assign a new value to x.
The body is an expression, so there is no return statement.
Call the function with square 3. Names of functions and bindings
start with a lowercase letter; type names such as Int start uppercase.
-- starts a comment.
Function application binds more tightly than arithmetic:
square 3 + 1 means (square 3) + 1. Use parentheses when an argument
is itself an expression. f x y groups as (f x) y; that grouping
rule does not specify which expression Haskell evaluates first.
Thinking time!
The starter’s nextSquare 3 is meant to square the next integer.
Will it print 10, 16, or a type error? Write down your prediction,
then choose Run.
Compare after running
It prints 10: the starter squares 3, then adds 1. To square
the next integer, the addition must be part of the argument.
Your challenge
Repair nextSquare n to return the square of n + 1 for any Int
in the small ranges used here. Try a negative number and zero before
choosing Test My Work. Explain why the same definition works for both.
main = print (...) is the supplied display harness. IO () marks
an input/output action; () is its result type. Your function computes
a value, and main displays it. The browser uses MicroHs, a compact
Haskell implementation; course-style definitions go in Main.hs, not
interpreter commands such as :load.
A failing check is information about one input, not a verdict on your ability. Read its description, make a prediction, and change one idea.
module Main where
square :: Int -> Int
square x = x * x
nextSquare :: Int -> Int
nextSquare n = square n + 1
main :: IO ()
main = print (nextSquare 3)
Solution
module Main where
square :: Int -> Int
square x = x * x
nextSquare :: Int -> Int
nextSquare n = square (n + 1)
main :: IO ()
main = print (nextSquare 3)
The parentheses make n + 1 the argument. Directly writing
(n + 1) * (n + 1) is also correct; the checks assess the function’s
result, not which helper you use.
Step 1 — Knowledge Check
Min. score: 80%
1. With triple x = 3 * x, what is triple 2 + 4?
The expression groups as (triple 2) + 4, giving 6 + 4.
2. What does square x = x * x do with x?
The definition describes a value for each input, without modifying the input.
Types and Numeric Boundaries
Why this matters
A program can compile and still throw away the information you need. Types help you decide what a value can represent, but you still choose the operation.
🎯 You will learn to
- Apply numeric conversion before fractional division
- Distinguish type inference from runtime type changes
Int represents bounded whole numbers, Integer supports arbitrary
precision, and Double represents approximate floating-point numbers.
Bool has the values True and False. 'x' is a Char, while
"x" is a String. A type is part of the program even when its
signature is omitted: Haskell infers types before executing your code.
div performs integral division. Write div 7 2 or use backticks:
7 `div` 2. Both give 3. / is fractional division, so convert
an existing Int with fromIntegral when a fractional result is needed.
For example, fromIntegral 3 * 2.5 :: Double gives 7.5.
Numeric literals can adapt to an expected numeric type; existing
Int values do not silently become Double values.
Thinking time!
A team splits seven points between two players. The starter converts
its answer to Double. Does that restore the half point, yielding
3.5, or does it produce 3.0? Predict, then run.
Where the fraction went
Integral division already produced 3. Converting that result gives
3.0; it cannot recover information discarded earlier.
Your challenge
Repair pointsPerPlayer total players so it returns the fractional
share. Assume total >= 0 and players > 0. Use the provided types;
the checks allow floating-point rounding error.
For a short debugging experiment, replace an argument in main with
True and run. Read the type error, explain the mismatch, then undo
that experiment before testing your work. Type errors and incorrect
numerical results are two different kinds of feedback.
module Main where
pointsPerPlayer :: Int -> Int -> Double
pointsPerPlayer total players = fromIntegral (total `div` players)
main :: IO ()
main = print (pointsPerPlayer 7 2)
Solution
module Main where
pointsPerPlayer :: Int -> Int -> Double
pointsPerPlayer total players = fromIntegral total / fromIntegral players
main :: IO ()
main = print (pointsPerPlayer 7 2)
The result signature selects Double, and / now operates on converted
operands. The positive-player precondition avoids division by zero.
Step 2 — Knowledge Check
Min. score: 80%
1. Which result does fromIntegral (9 `div` 2) :: Double produce?
The inner result is 4, and its Double representation is 4.0.
2. A definition omits its type signature. What does Haskell do?
Type inference supplies a static type. Some inferred types can be polymorphic, as later steps explore.
Conditional Values and Guards
Why this matters
Your program often has to choose between several answers. In Haskell, a conditional chooses a value, and the order of overlapping rules matters.
🎯 You will learn to
- Analyze which guard handles a boundary value
- Create a complete classification function
An if is an expression: if score >= 60 then "Pass" else "Retry".
It needs both branches, and their types must agree. Comparisons use
==, /=, <, <=, >, and >=; Boolean combinations use &&
and ||. Notice the distinction between defining with = and
comparing with ==.
Guards give a readable form for several conditions:
ticket age
| age < 12 = "Child"
| age < 18 = "Teen"
| otherwise = "Adult"
Read from the top. The first true guard supplies the result.
otherwise is the Boolean value True, so it provides a final fallback.
Thinking time!
The starter has a rule for scores of at least 90. Will rankScore 95
return "Gold" or "Silver"? Name the first matching guard before running.
Your challenge
Repair rankScore for this complete rule:
- Below 60:
"Practice" - From 60 through 89:
"Silver" - At least 90:
"Gold"
The function accepts any Int, including values outside the usual
score range. Test 59, 60, 89, and 90 yourself. A large sample such as
95 tells you less about the exact threshold than 89 and 90 together.
Guards or an equivalent nested if are both valid.
module Main where
rankScore :: Int -> String
rankScore score
| score >= 60 = "Silver"
| score >= 90 = "Gold"
| otherwise = "Practice"
main :: IO ()
main = print (rankScore 95)
Solution
module Main where
rankScore :: Int -> String
rankScore score
| score >= 90 = "Gold"
| score >= 60 = "Silver"
| otherwise = "Practice"
main :: IO ()
main = print (rankScore 95)
The most restrictive successful range comes first. A score that reaches the Silver guard is already known to be below 90.
Step 3 — Knowledge Check
Min. score: 80%
1. Why can moving an otherwise guard to the top change a function’s result?
An unconditional true guard at the top prevents later alternatives from being selected.
2. What is wrong with if score > 10 then 1 else False?
One expression must have one consistent type; a numeric result and Bool cannot serve as its alternatives.
Local Bindings
Why this matters
A long expression can hide a simple idea. Local names let you explain the pieces without turning them into mutable variables.
🎯 You will learn to
- Apply
letandwhereto name intermediate results - Analyze a calculation as a set of relationships
A let expression binds names and uses them after in:
perimeter side =
let edges = 4
in edges * side
A where clause attaches local definitions to an equation, including
its guards:
perimeter side = edges * side
where edges = 4
Keep definitions in the same block aligned; indentation is meaningful.
Both forms can define helper functions as well as data. A helper can
refer to the surrounding function’s parameters. Neither form is a
sequence of assignments: let x = x + 1 in x is a recursive definition,
not an increment operation.
Thinking time!
Four snacks cost 8 points each. A group order of at least four snacks gets a discount of one quarter of the subtotal, rounded down. Delivery costs 3 points. Should the total be 24, 27, or 35? Work through each named amount, then run the starter and locate the missing relationship.
Your challenge
Repair snackBill count unitCost fee to charge the subtotal minus
the discount, plus the fee once. Inputs are nonnegative integers.
The discount applies when count >= 4; even an empty order retains
the supplied fee. Keep intermediate names that make the rule readable.
After the checks pass, rewrite your definition using let ... in
instead of where and run the checks again. Explain why moving the
definitions on the page does not change the answer.
module Main where
snackBill :: Int -> Int -> Int -> Int
snackBill count unitCost fee = subtotal - discount
where
subtotal = count * unitCost
discount = if count >= 4 then subtotal `div` 4 else 0
main :: IO ()
main = print (snackBill 4 8 3)
Solution
module Main where
snackBill :: Int -> Int -> Int -> Int
snackBill count unitCost fee = subtotal - discount + fee
where
subtotal = count * unitCost
discount = if count >= 4 then subtotal `div` 4 else 0
main :: IO ()
main = print (snackBill 4 8 3)
Local definitions describe the subtotal and discount; the final expression combines them with the fee. An equivalent let expression changes scope syntax, not the billing rule.
Step 4 — Knowledge Check
Min. score: 80%1. What value does this expression produce?
let x = 5
y = x + 2
in x + y
x denotes 5 and y denotes 7, so their sum is 12. Binding y does not change x.
2. A local helper needs the enclosing function’s parameter. What can it do?
The enclosing parameter is visible to local helpers. Later, returning such a helper will let us explore closures.
Tuples and Type Variables
Why this matters
A score usually belongs to someone. A tuple keeps related values together while allowing each position to have its own type.
🎯 You will learn to
- Analyze tuple shapes and polymorphic type variables
- Create a new tuple that preserves one field and changes another
("Mina", 40) has type (String, Int) when its score is an Int.
Tuple size and position types are fixed: a pair is different from a
triple. A tuple parameter is one argument, even though it contains
several components.
swapPair :: (a, b) -> (b, a)
swapPair (left, right) = (right, left)
(left, right) is a pattern that names the two components.
Lowercase type variables a and b describe arbitrary types. Reusing
the same letter requires the same type; different letters permit
different types but do not require them to differ. Thus swapPair
works for both (True, 'x') and (3, 4).
Thinking time!
The starter defines original = ("Mina", 40) and calls addBonus 5 original.
After the repair, should printing original give 40 or 45 as its score?
Commit to an answer before you change the function.
Your challenge
Implement addBonus bonus (name, score) so it returns the same name
paired with score + bonus. Bonuses may be negative. Preserve the
original pair; creating an updated result does not mutate it.
Run the starter, repair it, and run again. The supplied do block
sequences two print actions so you can compare the new pair and the
original. For now, leave that display harness alone.
Before moving on, explain (a, a) versus (a, b) without looking back.
This is a good stopping point: take a break, then recall that distinction
before starting lists.
module Main where
swapPair :: (a, b) -> (b, a)
swapPair (left, right) = (right, left)
addBonus :: Int -> (String, Int) -> (String, Int)
addBonus bonus (name, score) = (name, score)
original :: (String, Int)
original = ("Mina", 40)
main :: IO ()
main = do
print (addBonus 5 original)
print original
Solution
module Main where
swapPair :: (a, b) -> (b, a)
swapPair (left, right) = (right, left)
addBonus :: Int -> (String, Int) -> (String, Int)
addBonus bonus (name, score) = (name, score + bonus)
original :: (String, Int)
original = ("Mina", 40)
main :: IO ()
main = do
print (addBonus 5 original)
print original
Pattern matching exposes the old components. The result constructs a new pair; original remains (“Mina”, 40). A helper using fst and snd instead of a tuple pattern would produce the same behavior.
Step 5 — Knowledge Check
Min. score: 80%
1. Which argument fits a parameter of type (a, a)?
Both positions must have the same type. Two Char values meet that requirement.
2. Recall square x = x * x. What does square (2 + 3) return?
The grouped argument is 5, whose square is 25. The same grouping rule applies around tuple arguments.
Lists and Strings
Why this matters
Lists describe collections whose length can vary. Their element type stays consistent, which lets one operation work for songs, numbers, or characters.
🎯 You will learn to
- Distinguish cons from list concatenation
- Apply a polymorphic list transformation to strings and other lists
[2, 4, 6] is a list of numbers; ["tea", "mochi"] is a list of
strings. Unlike tuples, every element of one list has the same type.
[] is empty. String is another name for [Char]: "hi" and
['h', 'i'] represent the same string.
x : xs puts one element at the front of a list.
xs ++ ys joins two lists, preserving their order.
For example, 1 : [2,3] and [1] ++ [2,3] both give [1,2,3].
"tea" : ["mochi"] is a list of strings; "tea" ++ "mochi" is one string.
take 2 xs keeps up to the first two items; drop 2 xs removes up
to the first two. length xs counts items, null xs tests whether
the list is empty, and x `elem` xs tests whether it contains x.
head xs returns the first item and tail xs returns the remaining
list; both require a nonempty list. xs !! i selects an item at a
valid zero-based index.
Enumerations such as [1..4] and [2,4..10] include the endpoint
when the step reaches it.
Thinking time!
What are the lengths of "tea", ["tea"], and []? Predict 3/1/0
or 1/3/0, then use main to check. Explain what counts as an element.
Your challenge
Implement bookend item items: place item at both ends, preserving
every existing element in its original order. Empty input still gets
two copies of the item. The type [a] should work for any element type.
Try bookend '!' "hi" and bookend "intro" ["song"]. Predict each
result’s type before running. The original input list stays unchanged.
module Main where
bookend :: a -> [a] -> [a]
bookend item items = items
main :: IO ()
main = do
print (length "tea", length ["tea"], length ([] :: [Int]))
print (bookend '!' "hi")
print (bookend "intro" ["song"])
Solution
module Main where
bookend :: a -> [a] -> [a]
bookend item items = item : (items ++ [item])
main :: IO ()
main = do
print (length "tea", length ["tea"], length ([] :: [Int]))
print (bookend '!' "hi")
print (bookend "intro" ["song"])
Cons constructs the front; concatenation preserves the middle and adds
the final singleton. [item] ++ items ++ [item] is also correct.
No Eq or numeric constraint is needed because the operation neither
compares nor calculates with the elements.
Step 6 — Knowledge Check
Min. score: 80%
1. What is the result of "a" : ["bc"]?
The result has two String elements, so its type is [String].
2. Which type best describes bookend without restricting its element type?
The same type variable connects the item, the input elements, and the output elements.
Complete List Patterns
Why this matters
A list might be empty, short, or much longer than your example. Patterns expose those shapes directly, so missing cases become easier to spot.
🎯 You will learn to
- Analyze exact-length and nonempty-list patterns
- Create a function that handles every list length
Compare a list’s construction with its decomposition. x : xs
constructs a list; the parameter pattern (x:xs) extracts its first
element into x and its remaining list into xs.
firstOr fallback [] = fallback
firstOr fallback (x:xs) = x
Equations are tried from top to bottom. [] matches an empty list,
[x] exactly one element, [x,y] exactly two, and (x:y:rest) at
least two. _ matches a value you do not need to name.
A case expression selects a pattern inside another expression:
firstOr fallback values = case values of
[] -> fallback
x:_ -> x
Here alternatives use -> rather than the = of function equations.
Thinking time!
Which inputs match [x,y]: [5], [5,7], or [5,7,9]? Which match
(x:y:_)? Predict the difference before trying examples.
Your challenge
Implement secondOr fallback values. Return the second element when
there are at least two; otherwise return the supplied fallback.
Preserve polymorphism: it must work for numbers, strings, and characters.
The starter always returns the fallback. Run it, then replace that
single catch-all with complete cases. Use either function equations
or case. Explain why matching exactly two elements would miss
a valid input.
module Main where
firstOr :: a -> [a] -> a
firstOr fallback [] = fallback
firstOr fallback (x:xs) = x
secondOr :: a -> [a] -> a
secondOr fallback values = fallback
main :: IO ()
main = print (secondOr "silence" ["intro", "chorus", "outro"])
Solution
module Main where
firstOr :: a -> [a] -> a
firstOr fallback [] = fallback
firstOr fallback (x:xs) = x
secondOr :: a -> [a] -> a
secondOr fallback (_:second:_) = second
secondOr fallback _ = fallback
main :: IO ()
main = print (secondOr "silence" ["intro", "chorus", "outro"])
The successful pattern proves that a second element exists. The final equation handles every shorter list. Explicit empty and singleton equations, or an equivalent case expression, are equally valid.
Step 7 — Knowledge Check
Min. score: 80%
1. Matching (x:xs) against ["cat"], what are x and xs?
The outer list has one String element and an empty tail.
2. A catch-all equation appears before specific list patterns. Which rule applies?
As with the earlier guard example, place specific cases before an unconditional fallback.
Structural Recursion
Why this matters
You already know how to process one item. Recursion connects that small decision to the rest of a collection, without a loop counter to mutate.
🎯 You will learn to
- Analyze the base case and progress of a recursive definition
- Create a list reduction from a per-element decision
Here is a complete recursive sum:
total [] = 0
total (x:xs) = x + total xs
The empty list supplies an answer immediately. The nonempty case
combines one element with the result for a smaller input.
For [4,2], expand 4 + total [2], then 4 + (2 + total []),
and finally 4 + (2 + 0). Each call has its own parameter bindings.
Thinking time!
Suppose a counter has base case count [] = 1 and adds one per
element. Would a list of three items produce 3 or 4? Explain where
the extra unit comes from before running anything.
Your challenge
Implement countAtLeast threshold scores: count every score greater
than or equal to the threshold, including repeated scores. Return
zero for an empty list. Scores and the threshold can be negative.
Run the starter, which currently reports zero for every input. Build from the sum example: decide whether the first score contributes zero or one, then combine it with the result for the tail. Recursion is the practice focus; any behaviorally equivalent implementation passes.
Before testing, trace [60,59,60] with threshold 60. Name the input
that gets smaller and the value supplied by the base case. If a
program keeps running, use Stop, then check whether the recursive
call receives the tail rather than the original list.
module Main where
total :: [Int] -> Int
total [] = 0
total (x:xs) = x + total xs
countAtLeast :: Int -> [Int] -> Int
countAtLeast threshold scores = 0
main :: IO ()
main = print (countAtLeast 60 [60, 59, 60])
Solution
module Main where
total :: [Int] -> Int
total [] = 0
total (x:xs) = x + total xs
countAtLeast :: Int -> [Int] -> Int
countAtLeast threshold [] = 0
countAtLeast threshold (x:xs) = contribution + countAtLeast threshold xs
where contribution = if x >= threshold then 1 else 0
main :: IO ()
main = print (countAtLeast 60 [60, 59, 60])
The empty list contributes zero. Each score contributes one precisely when it meets the threshold, and the recursive call handles every remaining occurrence. The comparison includes equality.
Step 8 — Knowledge Check
Min. score: 80%
1. Why does the recursive sum call total xs rather than total (x:xs)?
Removing one element makes finite input smaller. Calling the same definition on unchanged input makes no progress.
2. A counter uses x > threshold. Which input best reveals the missing equality case?
At equality, the required >= rule counts one while > counts zero. This reuses the guard-boundary strategy.
List Comprehensions
Why this matters
Sometimes a collection is easiest to describe by the candidates you allow. A comprehension names those candidates, filters them, and builds a result.
🎯 You will learn to
- Analyze generator order and inclusive numeric ranges
- Create a list of pairs satisfying a stated condition
[2 * n | n <- [1..4], n > 2]
Read it as: take each n from 1 through 4, keep those greater than
2, and emit 2 * n. The answer is [6,8]. <- introduces a generator;
a Boolean qualifier filters candidates. The expression before |
constructs each output element.
With two generators, the right one varies fastest:
[(x,y) | x <- [1,2], y <- [3,4]] produces
[(1,3),(1,4),(2,3),(2,4)], rather than pairing positions.
Later generators may depend on earlier values, such as y <- [x..4].
Thinking time!
Predict [(x,y) | x <- [1,2], y <- [x..2]].
Is (2,1) included? Is (2,2) included? Explain each decision,
then temporarily print the expression to compare.
Your challenge
Implement snackPairs limit target: return every pair (a,b) with
1 <= a <= b <= limit and a + b == target. Think of a and b as
snack prices. Equal prices are allowed; reversed duplicates are not.
Order by increasing a, then increasing b. For a nonpositive limit,
return the empty list. For example, snackPairs 5 6 is
[(1,5),(2,4),(3,3)].
The starter already generates candidates but keeps too many. Repair its qualification. Comprehensions are the practice focus; equivalent recursive solutions are accepted. Explain which part controls shape, which controls order, and which enforces the budget total.
module Main where
snackPairs :: Int -> Int -> [(Int, Int)]
snackPairs limit target = [(a,b) | a <- [1..limit], b <- [a..limit]]
main :: IO ()
main = print (snackPairs 5 6)
Solution
module Main where
snackPairs :: Int -> Int -> [(Int, Int)]
snackPairs limit target = [(a,b) | a <- [1..limit], b <- [a..limit], a + b == target]
main :: IO ()
main = print (snackPairs 5 6)
Starting b at a prevents reversed duplicates while retaining (a,a). The final qualifier enforces the exact sum. An empty candidate range naturally handles nonpositive limits.
Step 9 — Knowledge Check
Min. score: 80%
1. What does [n | n <- [2,4..8], n /= 4] produce?
The generator gives 2, 4, 6, 8, and the qualifier removes 4.
2. Which result describes [(x,y) | x <- [1,2], y <- [7,8]]?
For each x, the comprehension visits every y before moving to the next x.
The Snack Budget Challenge
Why this matters
A technique is useful when you can recognize when to use it. This challenge combines the pieces without giving you the next line to write.
🎯 You will learn to
- Create a recursive solution from a behavioral specification
- Evaluate a stopping rule using contrasting inputs
Your challenge
Implement takeBudget budget costs. Walk the snack prices in the
given order and return the longest prefix you can afford in total.
Once the next snack costs more than the remaining budget, stop.
Do not skip it to buy cheaper snacks later. Budgets and prices are
nonnegative integers, and free snacks are allowed.
Examples of the specification:
takeBudget 7 [3,4,1]gives[3,4].takeBudget 5 [6,1,1]gives[].takeBudget 0 [0,0,2]gives[0,0].
Thinking time!
Before editing, predict takeBudget 5 [2,4,1]. Choose [2], [2,1],
or [2,4], and justify it from the word prefix. Run the starter
to see which tempting but incomplete strategy it currently follows.
Write your own plan as three cases: empty input, an affordable first snack, and an unaffordable first snack. Decide what information the smaller recursive problem needs. Then implement and test your plan. You may use helpers or library functions; the checks measure the stated behavior. Resist reading a solution before you have tried a complete plan.
A short return visit
Tomorrow, recreate this function without looking at today’s code. Then explain why its stopping rule differs from selecting every individually cheap snack. If you study with someone, exchange one input that would expose the other’s likely bug and explain it.
Continue with Part 2: Functions and Laziness. It turns recurring list patterns into reusable higher-order functions.
module Main where
takeBudget :: Int -> [Int] -> [Int]
takeBudget budget costs = [cost | cost <- costs, cost <= budget]
main :: IO ()
main = print (takeBudget 5 [2,4,1])
Solution
module Main where
takeBudget :: Int -> [Int] -> [Int]
takeBudget budget [] = []
takeBudget budget (cost:rest)
| cost <= budget = cost : takeBudget (budget - cost) rest
| otherwise = []
main :: IO ()
main = print (takeBudget 5 [2,4,1])
Empty input gives an empty prefix. Buying an affordable snack reduces the budget for the recursive tail. An unaffordable snack ends the prefix immediately, so later cheap items cannot sneak into the result. A zero budget can still buy a zero-priced snack.
Step 10 — Knowledge Check
Min. score: 80%
1. Why does testing takeBudget 8 [3,4] alone miss a function that checks each price against 8?
Both items cost at most 8 and their total is only 7. Use an input whose total exceeds the budget to separate the strategies.
2. What must remain true after accepting the first cost?
Passing the remaining budget carries the needed information into the smaller problem.