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 -x
ghci> absolute 5
5
ghci> absolute (-3)
3

This 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 falseBranch

An 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 = -x

In 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:

  1. The condition has type Bool.
  2. 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)
10

The 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:

OperatorMeaningExample
==Equal3 == 3
/=Not equal3 /= 4
<Less than3 < 5
<=Less than or equal3 <= 5
>Greater than5 > 3
>=Greater than or equal5 >= 5
&&Logical ANDTrue && False
||Logical ORTrue || False
notLogical negationnot 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 <= 100
ghci> inRange 50
True
ghci> inRange 150
False
ghci> not (3 > 5)
True

not 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)^3

An alternative introduces a local name:

f x =
  let y = x + 1
  in y^2 + y^3

The syntax is:

let name = expression1 in expression2

The 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)
19

This source-file function has several local bindings:

f x =
  let y = x + 1
      z = y * y
  in z + y

For 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 * y

y 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 * y

For f 3, the scopes look like this:

ScopeBindingValue when x = 3
Outer lety = x + 14
Inner lety = x + 25
Inner bodyy * y uses the inner y25

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 + 1

For this example, it defines the same function as:

f x =
  let y = x + 1
  in y^2 + y^3

The 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 - 2

This 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 1

An alternative uses guards:

sign :: Int -> Int
sign x
  | x < 0     = -1
  | x == 0    = 0
  | otherwise = 1

A 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  = expression3

otherwise is not a keyword. The Prelude defines it as a Boolean value equivalent to True:

otherwise :: Bool
otherwise = True

Those 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 + 3

The 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 + 3

Both 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 + a

the 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 = 100

The 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 = x

This 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
30

Partial application supplies a convenient specialized function:

clampToHundred :: Int -> Int
clampToHundred = clampBetween 0 100

The 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 * y
Show 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 y
Show 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 = 10
Show 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 -x
Show solution
absolute x
  | x >= 0    = x
  | otherwise = -x

The 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 * 2
Show 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 * 2

This 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.

ConstructSyntaxRole
Conditionalif c then a else bSelect a result expression
Local expression bindinglet x = a in bGive an expression local names
Local equation helperswhereSupply declarations to an equation
Guards| condition = resultChoose the first successful conditional result
Catch-all guardotherwiseConventional name for True
LayoutAlignment and indentationGroup 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.