Beginner

Control Flow in F#

Master control flow in F# - expression-oriented if/else, pattern matching, loops, and recursion in a functional-first language

Control flow determines the order in which your program makes decisions and repeats work. In most imperative languages this means statements that branch and loop. F# takes a different, functional-first view: nearly everything is an expression that produces a value, and the workhorse for branching is pattern matching rather than long chains of if/else.

Because F# is multi-paradigm, it still offers familiar if/then/else, for, and while constructs. But idiomatic F# leans on pattern matching and recursion. An if/then/else returns a value, both branches must agree on a type, and you rarely need a break or continue because you express what you want rather than micromanaging the steps.

In this tutorial you’ll learn how F# handles conditional logic with if/elif/else expressions, how match expressions replace switch statements with far more power, how to write for and while loops, and how recursion takes the place of mutable-counter iteration in functional code.

Conditional Expressions: if / elif / else

In F#, if is an expression that evaluates to a value. When you use else, both branches must return the same type. When you omit else, the expression must return unit (used for side effects only).

Create a file named control_flow_if.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
let number = 7

// if/elif/else used for side effects (printing)
if number > 0 then
    printfn "%d is positive" number
elif number < 0 then
    printfn "%d is negative" number
else
    printfn "%d is zero" number

// Because if is an expression, it can return a value directly
let sign = if number > 0 then "positive" else "negative"
printfn "The sign is %s" sign

// Both branches must have the same type
let absValue = if number < 0 then -number else number
printfn "Absolute value of %d is %d" number absValue

The key insight: let sign = if ... then ... else ... assigns the result of the conditional. There is no separate ternary operator in F# because if/then/else already is one.

Pattern Matching: The Heart of F# Control Flow

The match expression is F#’s most powerful control-flow tool. It compares a value against a series of patterns, runs the first one that matches, and returns a value. Guards (when) add extra conditions, and _ is the catch-all wildcard.

Create a file named control_flow_match.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let describe n =
    match n with
    | 0 -> "zero"
    | 1 -> "one"
    | n when n < 0 -> "negative"
    | n when n % 2 = 0 -> "even"
    | _ -> "odd"

printfn "0 is %s" (describe 0)
printfn "1 is %s" (describe 1)
printfn "-5 is %s" (describe -5)
printfn "4 is %s" (describe 4)
printfn "7 is %s" (describe 7)

The compiler checks match expressions for completeness and warns you if you forget a case. Patterns are tried top to bottom, so order matters: the literal 0 and 1 cases are checked before the guarded cases.

Pattern Matching on Data Types

Pattern matching truly shines when destructuring discriminated unions and the Option type. This is how F# models choices and avoids null.

Create a file named control_flow_match_du.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// A discriminated union models a closed set of shapes
type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float
    | Square of side: float

let area shape =
    match shape with
    | Circle r -> System.Math.PI * r * r
    | Rectangle (w, h) -> w * h
    | Square s -> s * s

printfn "Circle area:    %.2f" (area (Circle 2.0))
printfn "Rectangle area: %.2f" (area (Rectangle (3.0, 4.0)))
printfn "Square area:    %.2f" (area (Square 5.0))

// The Option type replaces null - match on Some / None
let safeDivide x y =
    match y with
    | 0 -> None
    | _ -> Some (x / y)

match safeDivide 10 2 with
| Some result -> printfn "10 / 2 = %d" result
| None -> printfn "Cannot divide by zero"

match safeDivide 10 0 with
| Some result -> printfn "10 / 0 = %d" result
| None -> printfn "Cannot divide by zero"

Matching on Some/None forces you to handle the “no result” case at compile time, which is how F# eliminates an entire class of null-reference bugs.

Loops: for and while

F# supports imperative loops for when iteration is genuinely the clearest approach. The for ... in form iterates over a range or sequence, and while repeats while a condition holds. Note that mutating a counter requires the mutable keyword and the <- assignment operator.

Create a file named control_flow_loops.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// for..in over an inclusive range
printf "Countup: "
for i in 1..5 do
    printf "%d " i
printfn ""

// Ranges can include a step: start..step..finish
printf "Even numbers: "
for i in 0..2..10 do
    printf "%d " i
printfn ""

// while loop with a mutable counter
let mutable countdown = 5
printf "Countdown: "
while countdown > 0 do
    printf "%d " countdown
    countdown <- countdown - 1
printfn ""

Ranges like 1..5 and 0..2..10 are concise and read naturally. Unlike C-style languages, F# loops have no built-in break or continue — when you need early exit or to skip elements, recursion or sequence functions (Seq.takeWhile, Seq.filter) are the idiomatic choice.

Recursion: The Functional Loop

In functional code, recursion often replaces explicit loops. A recursive function is declared with let rec. Pattern matching combines beautifully with recursion to process lists and other structures.

Create a file named control_flow_recursion.fsx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Classic recursion: factorial
let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)

printfn "5! = %d" (factorial 5)

// Tail-recursive sum using an accumulator (efficient, no stack growth)
let rec sumTo acc n =
    if n = 0 then acc
    else sumTo (acc + n) (n - 1)

printfn "Sum 1..10 = %d" (sumTo 0 10)

// Recursion + pattern matching on a list
let rec length lst =
    match lst with
    | [] -> 0
    | _ :: rest -> 1 + length rest

printfn "Length of [1;2;3;4] = %d" (length [1; 2; 3; 4])

The length function matches the empty list [] against the “cons” pattern head :: rest, calling itself on the remaining tail. This recurse-on-the-tail style is the bread and butter of functional list processing.

Running with Docker

Run each script with F# Interactive (dotnet fsi) using the official .NET SDK image — no local install required.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Pull the official .NET SDK image
docker pull mcr.microsoft.com/dotnet/sdk:9.0

# Run the conditional expressions example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi control_flow_if.fsx

# Run the pattern matching example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi control_flow_match.fsx

# Run the discriminated union / Option example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi control_flow_match_du.fsx

# Run the loops example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi control_flow_loops.fsx

# Run the recursion example
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 dotnet fsi control_flow_recursion.fsx

Expected Output

Running control_flow_if.fsx:

7 is positive
The sign is positive
Absolute value of 7 is 7

Running control_flow_match.fsx:

0 is zero
1 is one
-5 is negative
4 is even
7 is odd

Running control_flow_match_du.fsx:

Circle area:    12.57
Rectangle area: 12.00
Square area:    25.00
10 / 2 = 5
Cannot divide by zero

Running control_flow_loops.fsx:

Countup: 1 2 3 4 5 
Even numbers: 0 2 4 6 8 10 
Countdown: 5 4 3 2 1 

Running control_flow_recursion.fsx:

5! = 120
Sum 1..10 = 55
Length of [1;2;3;4] = 4

Key Concepts

  • Everything is an expression - if/then/else returns a value, so it doubles as F#’s ternary operator; both branches must share a type.
  • Pattern matching over switch - match is exhaustive, supports guards (when), wildcards (_), and destructuring, and the compiler warns about missing cases.
  • Destructuring data - Matching on discriminated unions and the Option type (Some/None) is how F# handles variants safely and avoids null.
  • Loops exist but are secondary - for i in 1..5 and while work, but there is no break/continue; mutation needs mutable and the <- operator.
  • Ranges are first-class - start..finish and start..step..finish make counting loops concise.
  • Recursion replaces iteration - Declare with let rec; use an accumulator for tail recursion, and combine with pattern matching to walk lists.
  • Idiomatic F# - Prefer pattern matching and recursion (or sequence functions) over mutable loop counters when expressing functional logic.

Running Today

All examples can be run using Docker:

docker pull mcr.microsoft.com/dotnet/sdk:9.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining