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:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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.
| |
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/elsereturns a value, so it doubles as F#’s ternary operator; both branches must share a type. - Pattern matching over switch -
matchis exhaustive, supports guards (when), wildcards (_), and destructuring, and the compiler warns about missing cases. - Destructuring data - Matching on discriminated unions and the
Optiontype (Some/None) is how F# handles variants safely and avoidsnull. - Loops exist but are secondary -
for i in 1..5andwhilework, but there is nobreak/continue; mutation needsmutableand the<-operator. - Ranges are first-class -
start..finishandstart..step..finishmake 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
Comments
Loading comments...
Leave a Comment