Part 3 of Haskell from First Principles.
Expressions and functions can describe arithmetic, but a useful program also needs decisions and intermediate calculations. Haskell expresses both without requiring mutable assignment: an if selects a result, a let introduces local bindings, a where clause supplies helpers, and guards describe conditional equations.
Source-file definitions below are independent examples, sometimes reusing a name to show another formulation. Put definitions in a .hs file and evaluate expressions at the GHCi prompt. Do not combine alternative definitions of the same function in one scope.
For a multiline expression at the prompt, put :{ on a line before it and :} on a line after it, or enter the expression on one line. The exercise snippets show the expression itself without those GHCi delimiters.
3.1 Conditional expressions: if ... then ... else ...
Consider the mathematical absolute-value function:
It can be written as a source-file definition:
absolute :: Integer -> Integer
absolute x =
if x >= 0
then x
else -xghci> absolute 5
5
ghci> absolute (-3)
3This example uses arbitrary-precision Integer so that negating a negative input behaves like mathematical negation. An Int -> Int version works for ordinary small inputs, but the most negative Int has no representable positive counterpart.
The essential grammar is:
if condition then trueBranch else falseBranchAn imperative language often uses if to choose between statements. For example, Python can assign to a name in either branch:
if x >= 0:
y = x
else:
y = -xIn Haskell, if itself is an expression that supplies a result. It can therefore appear directly in a larger expression. These source-file functions illustrate both forms:
absoluteAgain x = if x >= 0 then x else -x
addPositivePart x = 10 + (if x > 0 then x else 0)The second corresponds to:
The else branch is mandatory. This is intentionally invalid syntax:
if x > 0 then x
-- Expected parse error: the else branch is missing.Both outcomes need a branch expression. The expression if x > 0 then x else 0 provides one for either condition, though—as with any Haskell expression—a chosen branch can itself fail or diverge.
3.2 Type checking of if
There are two main type requirements:
- The condition has type
Bool. - The branch types agree, so the complete expression has their common type.
For example:
ghci> if True then 3 else 5
3
ghci> if 2 > 0 then "positive" else "nonpositive"
"positive"The second expression has type String. This type rule can be written formally as:
Here stands for the typing information available for names in scope. The rule says that if the condition is Boolean and both branches have type , then the whole conditional has type .
These examples are deliberately invalid:
if True then 3 else False
-- Expected type error: the branches cannot share a type here.
if 3 then 10 else 20
-- Expected type error: the condition must be Bool.Haskell does not implicitly treat nonzero numbers as true. Also, both branches must type-check even when the condition is visibly True or False.
Evaluation is a separate matter. Only the selected branch is needed to produce the conditional’s result:
ghci> if True then 10 else (1 `div` 0)
10The unused branch is well-typed but would fail if evaluated. It is not demanded here. The distinction between type checking every branch and evaluating only the selected branch is central to understanding conditionals.
3.3 Boolean expressions
Conditions can be assembled with comparisons and Boolean operations:
| Operator | Meaning | Example |
|---|---|---|
== | Equal | 3 == 3 |
/= | Not equal | 3 /= 4 |
< | Less than | 3 < 5 |
<= | Less than or equal | 3 <= 5 |
> | Greater than | 5 > 3 |
>= | Greater than or equal | 5 >= 5 |
&& | Logical AND | True && False |
|| | Logical OR | True || False |
not | Logical negation | not True |
For example, a source-file definition can test whether an input belongs to a closed interval:
inRange :: Int -> Bool
inRange x = x >= 0 && x <= 100ghci> inRange 50
True
ghci> inRange 150
False
ghci> not (3 > 5)
Truenot is an ordinary prefix function. && and || short-circuit: the second operand is demanded only if the first does not already determine the result. For example, False && ... is False, and True || ... is True, without needing the second operand’s value.
3.4 Local definitions: let ... in ...
Suppose a function computes:
A direct definition repeats the expression x + 1:
f x = (x + 1)^2 + (x + 1)^3An alternative introduces a local name:
f x =
let y = x + 1
in y^2 + y^3The syntax is:
let name = expression1 in expression2The binding makes name available in the body expression2. The whole let ... in ... denotes the body’s result. For this nonrecursive example, it is useful to think of substituting x + 1 for each occurrence of y.
An entire let expression can be entered directly at the prompt or used inside a larger expression:
ghci> let x = 3 in x * x
9
ghci> 10 + (let x = 3 in x * x)
19This source-file function has several local bindings:
f x =
let y = x + 1
z = y * y
in z + yFor f 3, y denotes 4, z denotes 16, and the result is 20. Notice that y and z begin in the same column. Their alignment groups them into the same declaration block.
These are definitions with dependencies, rather than sequential assignments. In particular, Haskell’s let bindings are recursive: the local names are in scope throughout their declaration group, including their own right-hand sides. That fact will matter in the exercises.
3.5 Lexical scope and shadowing
A name’s scope is the region where a particular binding is available. In:
f x =
let y = x + 1
in y * yy is local to the let expression. It is not a new top-level name that other functions can use.
An inner scope can introduce another binding with the same name:
f x =
let y = x + 1
in
let y = x + 2
in y * yFor f 3, the scopes look like this:
| Scope | Binding | Value when x = 3 |
|---|---|---|
Outer let | y = x + 1 | 4 |
Inner let | y = x + 2 | 5 |
| Inner body | y * y uses the inner y | 25 |
The inner binding shadows the outer one. The outer y has not changed value; the name resolves to a different binding inside the nested expression. This is lexical scoping, not mutation.
The difference becomes especially important when a local name appears in its own definition. A new let x = ... shadows an outer x even inside that right-hand side. It is not an instruction to update the outer value.
3.6 Local definitions using where
A where clause provides another way to name intermediate expressions:
f x = y^2 + y^3
where
y = x + 1For this example, it defines the same function as:
f x =
let y = x + 1
in y^2 + y^3The main distinction is syntactic. let ... in ... is an expression and can appear inside any larger expression. A where clause attaches local declarations to an equation; it is not a standalone expression.
This is intentionally invalid:
10 + (where x = 3)
-- Expected parse error: where does not form an expression here.Several local definitions can support one function:
distanceSquared :: Double -> Double -> Double
distanceSquared x y = dx * dx + dy * dy
where
dx = x - 1
dy = y - 2This computes squared Euclidean distance from to :
dx and dy are local helpers and can refer to the function parameters. For example, distanceSquared 4 6 evaluates to 25.0.
Use let when a binding belongs within a particular expression; use where when a group of helpers supports a function equation. In the ordinary cases shown here, both introduce lexically scoped bindings and both support recursive local definitions.
3.7 Guards: an alternative to nested if
Many functions have a piecewise mathematical definition:
One possible source-file implementation uses nested conditionals:
sign :: Int -> Int
sign x =
if x < 0
then -1
else
if x == 0
then 0
else 1An alternative uses guards:
sign :: Int -> Int
sign x
| x < 0 = -1
| x == 0 = 0
| otherwise = 1A leading | introduces a condition. Conditions are tested in order, and the result associated with the first successful guard is selected. For sign (-5), the first guard x < 0 succeeds, so the result is -1; subsequent guards are not needed.
The simplified syntax is:
functionName parameters
| condition1 = expression1
| condition2 = expression2
| otherwise = expression3otherwise is not a keyword. The Prelude defines it as a Boolean value equivalent to True:
otherwise :: Bool
otherwise = TrueThose lines describe the existing Prelude binding; there is no need to redefine it. By convention, use otherwise for a final catch-all guard. The Haskell Report’s description of Bool specifies this binding.
Guards do not automatically make a function cover every input. If no guard succeeds and no later equation matches, demanding the result causes a pattern-match failure. A final catch-all often makes the intended coverage clearer.
These examples use Boolean guards. Haskell also supports pattern guards, but no pattern-guard syntax is required for this block.
3.8 Combining guards with where
A where clause can supply helpers to every guard in the same equation:
classify :: Integer -> String
classify x
| distance == 0 = "exact"
| distance < 5 = "close"
| otherwise = "far"
where
reference = 10
distance = abs (x - reference)abs is the Prelude’s absolute-value function. As with the mathematical absolute-value example, Integer keeps the distance calculation free from bounded-integer overflow.
ghci> classify 10
"exact"
ghci> classify 12
"close"
ghci> classify 20
"far"For classify 12, the distance is 2: it is not zero, but it is less than five, so the second guard supplies "close".
This is a common style: the guarded equation presents the main decision, and the where clause names the calculations supporting it. The helper bindings are available in the conditions as well as in the result expressions.
3.9 Indentation is part of Haskell’s grammar
Haskell usually uses layout rules instead of explicit braces to group declarations:
f x =
let y = x + 1
z = y * 2
in z + 3The bindings y and z align because they belong to the same group. This intentionally malformed version does not express that group correctly:
f x =
let y = x + 1
z = y * 2
in z + 3
-- Expected parse error caused by incorrect layout.The layout rules insert implicit delimiters. Explicit braces and semicolons provide an alternative way to write the valid definition:
f x = let { y = x + 1; z = y * 2 } in z + 3Both valid versions mean the same thing. Align declarations at the same structural level and indent nested expressions consistently.
Indentation determines syntactic grouping, not the temporal order of evaluation. In:
f x =
let a = x + 1
b = a * 2
in b + athe definitions describe dependencies. They are not instructions saying “first update a, then update b.” Local bindings are recursive, so a helper can also refer to another declared later in the same group. Evaluation is driven by what the result needs. The Haskell Report’s let specification describes these recursive declaration groups.
3.10 An example combining everything
Clamping restricts a value to an interval. For the interval :
Here is a source-file implementation:
clamp :: Int -> Int
clamp x
| x < lower = lower
| x > upper = upper
| otherwise = x
where
lower = 0
upper = 100The function has a signature, an equation with guarded results, and local constants. No mutable intermediate state is required.
Make the bounds parameters to obtain a reusable function:
clampBetween :: Int -> Int -> Int -> Int
clampBetween lower upper x
| x < lower = lower
| x > upper = upper
| otherwise = xThis definition assumes lower <= upper; otherwise the bounds do not describe the intended interval.
ghci> clampBetween 0 100 150
100
ghci> clampBetween 0 100 (-5)
0
ghci> clampBetween 0 100 30
30Partial application supplies a convenient specialized function:
clampToHundred :: Int -> Int
clampToHundred = clampBetween 0 100The first two arguments are fixed, leaving a function that accepts only the value to clamp. This connects guards and local definitions to currying from Block 2.
3.11 Exercises
Try each calculation or rewrite before opening the solution. Source-file definitions and GHCi expressions are labeled separately.
Exercise 1: follow local dependencies
What does this GHCi expression evaluate to?
let x = 3
y = x + 2
in x * yShow solution
15: x denotes 3, y denotes 5, and x * y is 15.
Exercise 2: combine a local name and a conditional
Given this source-file definition, what is f 5?
f x =
let y = x + 1
in if y > 5 then y * 2 else yShow solution
12. With x = 5, y is 6, so y > 5 is True and the selected branch yields 6 * 2.
Exercise 3: use a helper in a guard’s result
Given this source-file definition, what is f 7?
f x
| x > 0 = x + offset
| otherwise = 0
where
offset = 10Show solution
17. The first guard succeeds, and its result can refer to the where binding offset = 10.
Exercise 4: rewrite a conditional using guards
Rewrite this source-file function with guards:
absolute x =
if x >= 0 then x else -xShow solution
absolute x
| x >= 0 = x
| otherwise = -xThe first guard covers nonnegative inputs; the catch-all covers the remaining inputs. As in section 3.1, an Integer -> Integer signature gives the intended mathematical behavior for arbitrary integer inputs.
Exercise 5: a recursive-binding trap
Predict the result of this GHCi expression:
let x = 10
in let x = x + 1
in x * 2Show solution
It does not produce 22. The inner binding is recursive: the x on the right of the inner x = x + 1 refers to that same inner x, not to the outer x.
Demanding the inner value cannot produce a numeric result. Evaluation diverges or the runtime detects the recursive loop. To use the outer value, choose a different name:
let x = 10
in let y = x + 1
in y * 2This revised expression evaluates to 22.
The recursive-binding exercise captures why scope and evaluation order must be kept separate. let supplies definitions, and those definitions can refer to themselves; it does not perform a sequence of updates.
| Construct | Syntax | Role |
|---|---|---|
| Conditional | if c then a else b | Select a result expression |
| Local expression binding | let x = a in b | Give an expression local names |
| Local equation helpers | where | Supply declarations to an equation |
| Guards | | condition = result | Choose the first successful conditional result |
| Catch-all guard | otherwise | Conventional name for True |
| Layout | Alignment and indentation | Group declarations |
Next, Block 4 extends this style to lists, pattern matching, and recursion—the tools for processing structured data without imperative loops.
Adapted from Block 3 of the original GPT-produced Haskell tutorial.
Previous: Haskell 2: Types and Type Inference. Next: Haskell 4: Lists, Pattern Matching, and Recursion.