4 min read
On this page

Guards and Case

Pattern matching tells you the shape of a value. Guards let you ask additional questions about its contents. Case expressions let you do both inside any expression context, not just at the top of a function definition. These three constructs — pattern matching, guards, and case — are the bread and butter of Haskell control flow. Once you stop reaching for if reflexively, your code starts looking like Haskell.

Guards Add Conditions to Patterns

A pattern matches structure. A guard adds a boolean predicate. They combine with the | syntax:

classify :: Int -> String
classify n
  | n < 0     = "negative"
  | n == 0    = "zero"
  | n < 10    = "small"
  | n < 100   = "medium"
  | otherwise = "large"

Guards are checked top-to-bottom. The first that evaluates to True selects the right-hand side. otherwise is just a synonym for True, included for readability — there is no special syntax for "default."

Guards combine with patterns:

abs' :: Int -> Int
abs' n
  | n >= 0 = n
  | otherwise = -n

bmiTell :: Double -> Double -> String
bmiTell weight height
  | bmi < 18.5 = "underweight"
  | bmi < 25.0 = "normal"
  | bmi < 30.0 = "overweight"
  | otherwise  = "obese"
  where bmi = weight / height ^ (2 :: Int)

The where clause introduces local bindings visible across all guards. This is the canonical way to avoid recomputing intermediate values.

When Guards Beat If

Nested if-then-else produces unreadable Haskell. Guards flatten the structure:

-- Painful
shipping :: Double -> String -> Double
shipping weight country =
  if country == "US"
    then if weight < 1.0 then 5.0 else if weight < 5.0 then 10.0 else 25.0
    else if country == "CA"
      then if weight < 1.0 then 8.0 else 15.0
      else 50.0

-- Clear
shipping' :: Double -> String -> Double
shipping' weight country
  | country == "US" && weight < 1.0 = 5.0
  | country == "US" && weight < 5.0 = 10.0
  | country == "US"                 = 25.0
  | country == "CA" && weight < 1.0 = 8.0
  | country == "CA"                 = 15.0
  | otherwise                       = 50.0

if still has its place — typically when you have one condition with two outcomes, both used as expressions:

discountedPrice :: Double -> Bool -> Double
discountedPrice price isMember = price * if isMember then 0.9 else 1.0

The where Clause

where binds names visible in the enclosing function (including across all guards):

isPasswordStrong :: String -> Bool
isPasswordStrong pw
  | length pw < 8                  = False
  | not (any isUpper pw)           = False
  | not (any isDigit pw)           = False
  | otherwise                      = True
  where
    -- Could compute things here, available to all guards

For more complex examples, where becomes a small local module:

quadratic :: Double -> Double -> Double -> [Double]
quadratic a b c
  | discriminant < 0  = []
  | discriminant == 0 = [root]
  | otherwise         = [root + offset, root - offset]
  where
    discriminant = b * b - 4 * a * c
    root = -b / (2 * a)
    offset = sqrt discriminant / (2 * a)

The intermediate values exist solely to clarify the code. Naming them gives readers something to grab onto.

let ... in for Inline Bindings

let is the expression-form of where. It works inside any expression:

cylinderArea :: Double -> Double -> Double
cylinderArea r h =
  let sideArea = 2 * pi * r * h
      topArea  = pi * r * r
  in sideArea + 2 * topArea

The difference between let and where:

  • let ... in expr is itself an expression. It works in any expression context.
  • where is a clause that hangs off function equations and case branches. It is not an expression.

Most Haskell code uses where because it reads more naturally — the function body comes first, supporting definitions follow. Use let when you genuinely need an expression, like inside do blocks or list comprehensions:

-- let inside a list comprehension
heavyItems :: [(String, Double)] -> [String]
heavyItems items = [name | (name, weight) <- items, let pounds = weight * 2.205, pounds > 100]

Case Expressions

case is pattern matching as an expression. Unlike function-equation pattern matching (which only works at the top of a function), case works inside any expression:

describeList :: [a] -> String
describeList xs = "The list is " ++ case xs of
  []       -> "empty."
  [_]      -> "a singleton."
  [_, _]   -> "a pair."
  _        -> "longer."

This is functionally equivalent to writing multiple equations, but case is the only option when you need to match in the middle of an expression:

import qualified Data.Map.Strict as Map

processConfig :: Map.Map String String -> String
processConfig config =
  let timeout = case Map.lookup "timeout" config of
        Just s  -> read s
        Nothing -> 30 :: Int
      mode = case Map.lookup "mode" config of
        Just "fast"   -> "fast mode"
        Just "safe"   -> "safe mode"
        _             -> "default mode"
  in mode ++ " with timeout " ++ show timeout

Case With Guards

Case branches accept guards just like function equations:

classifyAge :: Int -> String
classifyAge age = case age of
  n | n < 0     -> "invalid"
    | n < 13    -> "child"
    | n < 20    -> "teen"
    | n < 65    -> "adult"
    | otherwise -> "senior"

The pattern n matches anything (effectively binding the value to n); the guards then partition the matches.

When to Use Case vs Multiple Equations

Multiple function equations are cleaner when matching is the entire function body:

-- Idiomatic
length' :: [a] -> Int
length' [] = 0
length' (_:xs) = 1 + length' xs

-- Awkward with case
length'' :: [a] -> Int
length'' xs = case xs of
  []     -> 0
  (_:ys) -> 1 + length'' ys

Use case when:

  • You need to pattern match on something other than the function arguments
  • You need pattern matching inside a let, do, or other expression context
  • You want to bind the result of a function call before matching, without using where
findFirst :: (a -> Bool) -> [a] -> String
findFirst predicate xs = case filter predicate xs of
  []      -> "not found"
  (x:_)   -> "found: " ++ show x

Common Pitfalls

Forgetting otherwise and falling through. If no guard matches, you get a runtime exception (Non-exhaustive guards). GHC warns about this with -Wincomplete-patterns enabled, which you should always turn on.

Confusing let and where inside do-blocks. Inside do, you write let x = ... (no in), and the binding is visible in subsequent statements. Outside do, let ... in expr requires the in clause.

Overusing case for trivial patterns. A boolean check does not need a case. if cond then x else y is fine. Reach for case when you have multiple constructors or list shapes to handle.

Shadowing names accidentally. A pattern variable shadows any outer binding with the same name. case x of x -> ... always succeeds (binding the new x to the matched value), which is rarely what you want. GHC warns about shadowing if you enable -Wname-shadowing.

Using where clauses across guards inconsistently. Definitions in where are visible to all guards in the equation, but not to other equations of the same function. Each equation has its own where.

Key Takeaways

  • Guards (|) add boolean predicates to pattern-matched function equations. Use them instead of nested if-then-else.
  • otherwise is just True — it has no special meaning, but it reads as the catch-all branch.
  • where introduces local bindings visible across guards in the same equation. It is the most common way to factor out intermediate values.
  • let ... in is the expression form. Use it inside expressions where where does not fit.
  • case is pattern matching as an expression, usable in any expression context. Multiple function equations are usually cleaner when the function is one big match.
  • Always enable -Wincomplete-patterns to catch missing branches at compile time.