Beginner

This beginner tutorial covers F#’s foundational syntax and functional-first paradigm through 30 heavily annotated examples. Each example demonstrates core concepts with detailed inline comments explaining the functional approach.

Example 1: Hello World and F# Interactive

F# is a functional-first language that compiles to .NET bytecode. You can run F# code interactively using F# Interactive (FSI) or compile it to executables.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[Source Code<br/>program.fs]:::blue --> B[F# Compiler<br/>fsc]:::orange
    A --> C[F# Interactive<br/>fsi]:::teal
    B --> D[Executable<br/>program.exe]:::orange
    C --> E[REPL Output]:::teal

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#DE8F05,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff

Code:

// Example 1: Hello World and F# Interactive
// => F# uses // for single-line comments

printfn "Hello, World!"  // => printfn prints to stdout with newline
                          // => Outputs: Hello, World!
                          // => No semicolons required (unlike C#)

Key Takeaway: F# uses printfn for formatted output with automatic newline. The language emphasizes expressions over statements, and semicolons are optional.

Why It Matters: F# Interactive (FSI) enables rapid prototyping and REPL-driven development similar to Python, but with .NET’s type safety and performance. Developers use FSI for testing algorithms interactively before deployment.

Example 2: Immutable Values with let

F# defaults to immutability - values bound with let cannot be changed. This eliminates entire classes of bugs related to unexpected mutations.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[let x = 10]:::blue --> B[Immutable Binding]:::orange
    B --> C[Cannot reassign x]:::teal
    A --> D[let x = 20<br/>New binding<br/>shadows old]:::purple

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#CC78BC,stroke:#000,color:#fff

Code:

// Example 2: Immutable Values with let
let x = 10               // => x is bound to 10 (type: int, inferred)
                         // => IMMUTABLE - cannot reassign x

// x <- 20                // => ERROR: cannot mutate immutable binding
                         // => F# compiler rejects mutation by default

let x = 20               // => SHADOWING: creates NEW binding
                         // => Previous x (10) is no longer accessible
                         // => New x is 20 in current scope

printfn "%d" x           // => Outputs: 20
                         // => %d is format specifier for integers

Key Takeaway: Use let for immutable bindings (default in F#). Shadowing creates new bindings; it doesn’t mutate existing values.

Why It Matters: Immutability by default prevents many concurrency bugs, as immutable data can be safely shared across threads without locks. F#’s compiler-enforced immutability makes concurrent code safer than C# or Java equivalents without sacrificing .NET interoperability.

Example 3: Type Inference

F#’s type inference system deduces types from usage, reducing boilerplate while maintaining full static typing.

// Example 3: Type Inference
let age = 30             // => age is int (inferred from literal 30)
                         // => No type annotation needed

let name = "Alice"       // => name is string (inferred from string literal)
                         // => F# infers most precise type

let pi = 3.14            // => pi is float (inferred from decimal literal)
                         // => float is 64-bit IEEE 754 (like double in C#)

let isValid = true       // => isValid is bool (inferred from boolean literal)
                         // => true/false are lowercase keywords

printfn "%d %s %.2f %b" age name pi isValid
                         // => Outputs: 30 Alice 3.14 true
                         // => %d=int, %s=string, %.2f=float (2 decimals), %b=bool

Key Takeaway: F# infers types from literals and operations. Explicit type annotations are rarely needed for local bindings.

Why It Matters: Type inference reduces code verbosity compared to Java or C# while maintaining compile-time safety, enabling developers to write concise yet statically-typed code. OCaml and Haskell pioneered this approach; F# brings it to .NET with full tooling support (IntelliSense, refactoring).

Example 4: Basic Types

F# provides standard primitive types compatible with .NET’s Common Type System.

// Example 4: Basic Types
let intValue: int = 42   // => int (32-bit signed integer)
                         // => Explicit type annotation with : type

let longValue = 42L      // => long (64-bit, suffix L)
                         // => Type inferred from suffix

let floatValue = 3.14    // => float (64-bit IEEE 754)
                         // => Default for decimal literals

let doubleValue = 3.14   // => Same as float (F# float IS .NET double)
                         // => No separate double type in F#

let charValue = 'A'      // => char (16-bit Unicode character)
                         // => Single quotes for characters

let stringValue = "text" // => string (immutable UTF-16 sequence)
                         // => Double quotes for strings

let byteValue = 255uy    // => byte (unsigned 8-bit, suffix uy)
                         // => Range: 0-255

printfn "%d %d %.2f %c %s %d" intValue longValue floatValue charValue stringValue byteValue
                         // => Outputs: 42 42 3.14 A text 255

Key Takeaway: F# types map directly to .NET types. Use suffixes (L, uy) for specific numeric types. float in F# equals double in C#.

Why It Matters: Full .NET type compatibility enables seamless interop with C# libraries and APIs while maintaining F#’s functional programming benefits, allowing teams to adopt F# incrementally within existing .NET codebases without rewriting infrastructure.

Example 5: Functions

Functions are first-class values in F# - they can be assigned to variables, passed as arguments, and returned from other functions.

// Example 5: Functions
let add x y = x + y      // => Function definition with two parameters
                         // => Type inferred: int -> int -> int (curried)
                         // => Parameters x and y are ints (from + operator)

let result = add 3 5     // => Function application (no parentheses)
                         // => result is 8

printfn "%d" result      // => Outputs: 8

let greet name =         // => Function with one parameter
    sprintf "Hello, %s!" name
                         // => sprintf returns formatted string (no printing)
                         // => Type: string -> string

let message = greet "Bob"
                         // => message is "Hello, Bob!" (type: string)

printfn "%s" message     // => Outputs: Hello, Bob!

Key Takeaway: Functions are defined with let name params = body. No return keyword - last expression is the return value.

Why It Matters: First-class functions enable functional programming patterns like map, filter, and reduce used in data processing pipelines. F# functions compose cleanly without side effects, making code easier to debug compared to imperative equivalents.

Example 6: Function Composition

The >> operator composes functions left-to-right, creating pipelines that transform data through multiple stages.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[Input: 5]:::blue --> B[double<br/>x * 2]:::orange
    B --> C[Result: 10]:::teal
    C --> D[addTen<br/>x + 10]:::orange
    D --> E[Final: 20]:::teal

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#DE8F05,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff

Code:

// Example 6: Function Composition
let double x = x * 2     // => Doubles input (int -> int)
                         // => double 5 returns 10

let addTen x = x + 10    // => Adds 10 to input (int -> int)
                         // => addTen 10 returns 20

let doubleThenAddTen = double >> addTen
                         // => Composition operator >> (left-to-right)
                         // => Creates NEW function: int -> int
                         // => Equivalent to: fun x -> addTen (double x)

let result = doubleThenAddTen 5
                         // => Applies: double 5 = 10, then addTen 10 = 20
                         // => result is 20

printfn "%d" result      // => Outputs: 20

Key Takeaway: Use >> to compose functions left-to-right. Composition creates new functions without intermediate variables.

Why It Matters: Function composition enables point-free style and data transformation pipelines. F# compilers optimize composed functions into efficient code without allocation overhead, making functional pipelines as fast as imperative loops while remaining easier to reason about and test in isolation.

Example 7: Piping with |>

The pipe operator |> feeds values through function chains, making data transformations read naturally from top to bottom.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[5]:::blue --> B[|> double]:::orange
    B --> C[10]:::teal
    C --> D[|> addTen]:::orange
    D --> E[20]:::teal

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#DE8F05,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff

Code:

// Example 7: Piping with |>
let double x = x * 2     // => Same double function from Example 6

let addTen x = x + 10    // => Same addTen function

let result = 5           // => Start with value 5
             |> double   // => Pipe to double: 5 |> double = double 5 = 10
             |> addTen   // => Pipe to addTen: 10 |> addTen = addTen 10 = 20
                         // => Final result is 20

printfn "%d" result      // => Outputs: 20
                         // => Same as doubleThenAddTen 5

Key Takeaway: |> passes the left value as the last parameter to the right function. Pipelines read top-to-bottom like Unix pipes.

Why It Matters: Piping eliminates nested function calls and intermediate variables, making data transformations more readable. Pipelines like data |> filter |> map |> aggregate clearly show transformation steps compared to nested imperative loops.

Example 8: Pattern Matching Basics

Pattern matching is F#’s primary control flow mechanism, replacing if/else chains with declarative case handling.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[match number]:::blue --> B{Pattern?}:::orange
    B -->|1| C[One]:::teal
    B -->|2| D[Two]:::teal
    B -->|_| E[Other]:::teal

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#029E73,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff

Code:

// Example 8: Pattern Matching Basics
let describe number =    // => Function taking int parameter
    match number with    // => match expression (F#'s primary conditional)
    | 1 -> "One"         // => Pattern: literal 1, result: "One"
                         // => -> separates pattern from result
    | 2 -> "Two"         // => Pattern: literal 2, result: "Two"
    | _ -> "Other"       // => _ is wildcard pattern (matches anything)
                         // => Must be EXHAUSTIVE (covers all cases)
                         // => Type: int -> string

let result1 = describe 1 // => Matches first pattern, result1 is "One"
let result2 = describe 2 // => Matches second pattern, result2 is "Two"
let result3 = describe 99// => Matches wildcard, result3 is "Other"

printfn "%s %s %s" result1 result2 result3
                         // => Outputs: One Two Other

Key Takeaway: Use match value with | pattern -> result for branching. Patterns must be exhaustive - compiler enforces this.

Why It Matters: Exhaustive pattern matching catches unhandled cases at compile time, preventing runtime errors. The compiler forces developers to handle all possibilities explicitly, reducing bugs related to missing cases compared to if/else chains.

Example 9: Lists

F# lists are immutable, singly-linked sequences. They’re the primary collection type for functional programming.

// Example 9: Lists
let numbers = [1; 2; 3; 4; 5]
                         // => List literal syntax: [element; element; ...]
                         // => Type: int list (immutable)
                         // => Semicolons separate elements

let empty = []           // => Empty list
                         // => Type: 'a list (generic placeholder)

let cons = 0 :: numbers  // => :: is cons operator (prepend)
                         // => Creates NEW list: [0; 1; 2; 3; 4; 5]
                         // => Original numbers is unchanged

let head = List.head numbers
                         // => Gets first element: 1
                         // => Throws exception if list is empty

let tail = List.tail numbers
                         // => Gets all elements except first: [2; 3; 4; 5]
                         // => Returns list (not element)

printfn "%A" numbers     // => Outputs: [1; 2; 3; 4; 5]
                         // => %A is generic format specifier
printfn "%A" cons        // => Outputs: [0; 1; 2; 3; 4; 5]
printfn "%d" head        // => Outputs: 1
printfn "%A" tail        // => Outputs: [2; 3; 4; 5]

Key Takeaway: Lists are immutable sequences. Use :: to prepend, List.head for first element, List.tail for rest.

Why It Matters: Immutable lists enable safe concurrent processing and structural sharing. F# shares unchanged portions across operations, using less memory than copying entire arrays in imperative languages while maintaining O(1) prepend performance.

Example 10: Recursion

Recursion is the primary iteration mechanism in functional programming, replacing loops with function calls.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[sum #91;1;2;3#93;]:::blue --> B{List empty?}:::orange
    B -->|Yes| C[Return 0]:::teal
    B -->|No| D[head + sum tail]:::purple
    D --> E[1 + sum #91;2;3#93;]:::brown
    E --> F[1 + 2 + sum #91;3#93;]:::brown
    F --> G[1 + 2 + 3 + 0 = 6]:::teal

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#CC78BC,stroke:#000,color:#fff
    style E fill:#CA9161,stroke:#000,color:#fff
    style F fill:#CA9161,stroke:#000,color:#fff
    style G fill:#029E73,stroke:#000,color:#fff

Code:

// Example 10: Recursion
let rec sum list =       // => rec keyword enables recursion
                         // => Function can call itself
    match list with      // => Pattern match on list structure
    | [] -> 0            // => Base case: empty list returns 0
                         // => Terminates recursion
    | head :: tail ->    // => Recursive case: decompose into head and tail
        head + sum tail  // => Add head to sum of remaining elements
                         // => Recursive call with smaller list

let result = sum [1; 2; 3; 4; 5]
                         // => sum [1;2;3;4;5]
                         // => = 1 + sum [2;3;4;5]
                         // => = 1 + 2 + sum [3;4;5]
                         // => = 1 + 2 + 3 + sum [4;5]
                         // => = 1 + 2 + 3 + 4 + sum [5]
                         // => = 1 + 2 + 3 + 4 + 5 + sum []
                         // => = 1 + 2 + 3 + 4 + 5 + 0 = 15

printfn "%d" result      // => Outputs: 15

Key Takeaway: Use rec keyword for recursive functions. Pattern match on data structure, handle base case first.

Why It Matters: F# optimizes tail recursion into loops, making recursive code as efficient as imperative loops without stack overflow risk. Compiler transforms tail-recursive functions into while loops internally, enabling processing of million-element lists with constant stack space usage.

Example 11: List.map - Transforming Collections

List.map applies a function to every element, returning a new list. It’s the functional equivalent of a for-each loop with transformation.

// Example 11: List.map - Transforming Collections
let numbers = [1; 2; 3; 4; 5]
                         // => Input list: [1; 2; 3; 4; 5]

let doubled = List.map (fun x -> x * 2) numbers
                         // => fun x -> x * 2 is anonymous function (lambda)
                         // => List.map applies function to each element
                         // => Creates NEW list: [2; 4; 6; 8; 10]
                         // => Original numbers unchanged

let squares = numbers |> List.map (fun x -> x * x)
                         // => Piping version (same as above)
                         // => squares is [1; 4; 9; 16; 25]

printfn "%A" doubled    // => Outputs: [2; 4; 6; 8; 10]
printfn "%A" squares    // => Outputs: [1; 4; 9; 16; 25]

Key Takeaway: List.map transforms each element independently. Use lambda fun x -> expression for inline functions.

Why It Matters: Mapping operations are automatically parallelizable and compose cleanly. F# can convert List.map operations to parallel LINQ when beneficial, processing large datasets faster on multi-core systems without code changes.

Example 12: List.filter - Selecting Elements

List.filter selects elements matching a predicate, returning a new list with only matching items.

// Example 12: List.filter - Selecting Elements
let numbers = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
                         // => Input list: 1 through 10

let evens = List.filter (fun x -> x % 2 = 0) numbers
                         // => Predicate: x % 2 = 0 (even numbers)
                         // => List.filter keeps elements where predicate is true
                         // => evens is [2; 4; 6; 8; 10]

let greaterThanFive = numbers |> List.filter (fun x -> x > 5)
                         // => Piping version
                         // => Predicate: x > 5
                         // => greaterThanFive is [6; 7; 8; 9; 10]

printfn "%A" evens      // => Outputs: [2; 4; 6; 8; 10]
printfn "%A" greaterThanFive
                         // => Outputs: [6; 7; 8; 9; 10]

Key Takeaway: List.filter selects elements where predicate returns true. Predicates are functions returning bool.

Why It Matters: Filter operations compose with map and fold to create data processing pipelines. E-commerce systems filter product catalogs by availability, then map to display models, processing millions of SKUs efficiently through composed functional operations.

Example 13: List.fold - Aggregating Values

List.fold reduces a list to a single value by repeatedly applying a function with an accumulator.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[Initial: 0]:::blue --> B[Fold step 1<br/>0 + 1 = 1]:::orange
    B --> C[Fold step 2<br/>1 + 2 = 3]:::orange
    C --> D[Fold step 3<br/>3 + 3 = 6]:::orange
    D --> E[Result: 6]:::teal

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#DE8F05,stroke:#000,color:#fff
    style D fill:#DE8F05,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff

Code:

// Example 13: List.fold - Aggregating Values
let numbers = [1; 2; 3; 4; 5]
                         // => Input list

let sum = List.fold (fun acc x -> acc + x) 0 numbers
                         // => List.fold takes: function, initial value, list
                         // => fun acc x -> acc + x is accumulator function
                         // => acc is accumulator (running total)
                         // => x is current element
                         // => Initial value: 0
                         // => Process: 0 + 1 = 1, 1 + 2 = 3, 3 + 3 = 6, etc.
                         // => sum is 15

let product = numbers |> List.fold (fun acc x -> acc * x) 1
                         // => Initial value: 1 (multiplicative identity)
                         // => Process: 1 * 1 = 1, 1 * 2 = 2, 2 * 3 = 6, etc.
                         // => product is 120

printfn "%d" sum        // => Outputs: 15
printfn "%d" product    // => Outputs: 120

Key Takeaway: List.fold accumulates values left-to-right. Start with appropriate initial value (0 for sum, 1 for product).

Why It Matters: Fold is the most general list operation - map and filter can be implemented using fold. Financial applications use fold to calculate running totals, averages, and complex aggregations over transaction streams without mutable state.

Example 14: Tuples

Tuples group multiple values of different types into a single composite value without defining a named type.

// Example 14: Tuples
let pair = (1, "one")    // => Tuple with 2 elements: int * string
                         // => Type: int * string (read as "int times string")
                         // => Immutable

let triple = (1, "one", true)
                         // => Tuple with 3 elements: int * string * bool
                         // => Type: int * string * bool

let x, y = pair          // => Tuple deconstruction (pattern matching)
                         // => x is 1 (int)
                         // => y is "one" (string)

let first = fst pair     // => fst gets first element of 2-tuple
                         // => first is 1

let second = snd pair    // => snd gets second element of 2-tuple
                         // => second is "one"
                         // => fst/snd only work with 2-tuples

printfn "%A" pair        // => Outputs: (1, "one")
printfn "%d %s" x y      // => Outputs: 1 one
printfn "%d %s" first second
                         // => Outputs: 1 one

Key Takeaway: Tuples use (value1, value2, ...) syntax. Deconstruct with let x, y = tuple or use fst/snd for pairs.

Why It Matters: Tuples provide lightweight data grouping without ceremony of defining classes or records. Functions commonly return tuples for multiple values: tryParse returns (bool * int) indicating success and parsed value, avoiding exceptions for parsing failures.

Example 15: Records

Records are immutable named data structures with labeled fields. They’re F#’s primary data modeling tool.

// Example 15: Records
type Person = {          // => Record type definition
    Name: string         // => Field Name of type string
    Age: int             // => Field Age of type int
}                        // => Records are immutable by default

let alice = {            // => Record construction
    Name = "Alice"       // => Field initialization with =
    Age = 30             // => All fields must be initialized
}                        // => Type: Person

let bob = { alice with Name = "Bob" }
                         // => Copy-and-update syntax
                         // => Creates NEW record copying alice
                         // => Updates Name field to "Bob"
                         // => Age remains 30 (from alice)
                         // => Original alice unchanged

printfn "%s is %d" alice.Name alice.Age
                         // => Dot notation for field access
                         // => Outputs: Alice is 30
printfn "%s is %d" bob.Name bob.Age
                         // => Outputs: Bob is 30

Key Takeaway: Records provide named fields with structural equality. Use { record with Field = value } to create modified copies.

Why It Matters: Records are immutable value types that prevent accidental sharing bugs. They generate automatic equality, comparison, and ToString implementations, reducing boilerplate compared to C# classes while ensuring thread safety through immutability.

Example 16: Discriminated Unions

Discriminated unions (DUs) define types that can be one of several named cases, each potentially carrying different data.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
graph TD
    A[Shape type]:::blue --> B[Circle of float]:::orange
    A --> C[Rectangle of float * float]:::teal
    A --> D[Triangle of float * float * float]:::purple

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#CC78BC,stroke:#000,color:#fff

Code:

// Example 16: Discriminated Unions
type Shape =             // => Discriminated union definition
    | Circle of radius: float
                         // => Case: Circle carrying float value
                         // => Named field: radius
    | Rectangle of width: float * height: float
                         // => Case: Rectangle carrying tuple of floats
    | Triangle of a: float * b: float * c: float
                         // => Case: Triangle carrying 3 floats

let circle = Circle(radius = 5.0)
                         // => Construct Circle case
                         // => Type: Shape

let rect = Rectangle(width = 4.0, height = 3.0)
                         // => Construct Rectangle case

let describeShape shape =// => Function matching on union cases
    match shape with
    | Circle(r) -> sprintf "Circle with radius %.1f" r
                         // => Extract radius as r
    | Rectangle(w, h) -> sprintf "Rectangle %.1fx%.1f" w h
                         // => Extract width and height
    | Triangle(a, b, c) -> sprintf "Triangle sides %.1f, %.1f, %.1f" a b c

printfn "%s" (describeShape circle)
                         // => Outputs: Circle with radius 5.0
printfn "%s" (describeShape rect)
                         // => Outputs: Rectangle 4.0x3.0

Key Takeaway: Discriminated unions model mutually exclusive choices. Pattern matching extracts case-specific data safely.

Why It Matters: DUs eliminate null reference errors and invalid state representations. Financial systems model trades as type Trade = Buy of ... | Sell of ..., making it impossible to represent invalid states like a trade being both buy and sell simultaneously.

Example 17: Option Type - Null Safety

The Option type represents values that may or may not exist, replacing null with a type-safe alternative.

  %% Color Palette: Blue #0173B2, Orange #DE8F05
graph TD
    A[Option type]:::blue --> B[Some of value]:::orange
    A --> C[None]:::orange

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#DE8F05,stroke:#000,color:#fff

Code:

// Example 17: Option Type - Null Safety
let someValue = Some 42  // => Option with value: Some 42
                         // => Type: int option

let noValue = None       // => Option with no value: None
                         // => Type: 'a option (generic)

let printOption opt =    // => Function handling option
    match opt with       // => Pattern match on option cases
    | Some value ->      // => Some case: extract value
        printfn "Value: %d" value
    | None ->            // => None case: no value present
        printfn "No value"

printOption someValue    // => Outputs: Value: 42
printOption noValue      // => Outputs: No value

let doubled = Option.map (fun x -> x * 2) someValue
                         // => Option.map applies function if Some
                         // => doubled is Some 84
let doubledNone = Option.map (fun x -> x * 2) noValue
                         // => doubledNone is None (map short-circuits)

printfn "%A" doubled     // => Outputs: Some 84
printfn "%A" doubledNone // => Outputs: None

Key Takeaway: Use Option instead of null. Pattern match on Some/None to handle both cases explicitly.

Why It Matters: Option types eliminate null reference exceptions through compile-time safety. Tony Hoare called null his “billion-dollar mistake”; F#’s option type forces developers to handle missing values explicitly, eliminating null-related production errors in codebases that ban nullable reference types.

Example 18: Unit Type

The unit type represents “no meaningful value” - similar to void in C# but is an actual value.

// Example 18: Unit Type
let printMessage msg =   // => Function taking string, returning unit
    printfn "%s" msg     // => printfn returns unit (no meaningful result)
                         // => Type: string -> unit

let result = printMessage "Hello"
                         // => result is () (unit value)
                         // => Outputs: Hello
                         // => Type: unit

let unitValue = ()       // => Explicit unit value
                         // => () is both the type name and value

printfn "%A" result      // => Outputs: ()
                         // => %A formats unit as ()

Key Takeaway: unit represents functions executed for side effects. () is the single value of type unit.

Why It Matters: Unit makes side effects explicit in type signatures. Functions returning unit signal they’re executed for effects (printing, I/O, mutations), not computation, improving code comprehension and testability by separating pure functions from effectful ones.

Example 19: String Manipulation

F# provides extensive string operations with immutability guarantees.

// Example 19: String Manipulation
let text = "Hello, World!"
                         // => Immutable string

let upper = text.ToUpper()
                         // => Creates NEW string in uppercase
                         // => upper is "HELLO, WORLD!"
                         // => Original text unchanged

let lower = text.ToLower()
                         // => lower is "hello, world!"

let substring = text.Substring(0, 5)
                         // => Extract substring from index 0, length 5
                         // => substring is "Hello"

let replaced = text.Replace("World", "F#")
                         // => Replace substring
                         // => replaced is "Hello, F#!"

let parts = text.Split(',')
                         // => Split by delimiter
                         // => parts is ["Hello"; " World!"] (string array)

let concatenated = "Hello" + " " + "F#"
                         // => String concatenation with +
                         // => concatenated is "Hello F#"

printfn "%s" upper       // => Outputs: HELLO, WORLD!
printfn "%s" substring   // => Outputs: Hello
printfn "%A" parts       // => Outputs: [|"Hello"; " World!"|]

Key Takeaway: String methods create new strings (immutable). Use + for concatenation, .Split() for parsing.

Why It Matters: Immutable strings prevent bugs from unexpected mutations and enable safe sharing across threads. The .NET string pool optimizes memory usage, making F# string operations as efficient as mutable alternatives without the complexity of defensive copying.

Example 20: Arrays

Arrays are mutable, fixed-size collections optimized for index-based access. Unlike lists, they allow in-place updates.

// Example 20: Arrays
let numbers = [|1; 2; 3; 4; 5|]
                         // => Array literal: [| elements |]
                         // => Type: int[] (mutable)
                         // => Fixed size after creation

let first = numbers.[0]  // => Index access with .[index]
                         // => first is 1
                         // => Zero-based indexing

numbers.[0] <- 10        // => MUTATION: Update element at index 0
                         // => <- is assignment operator
                         // => numbers is now [|10; 2; 3; 4; 5|]

let doubled = Array.map (fun x -> x * 2) numbers
                         // => Array.map (like List.map)
                         // => Creates NEW array: [|20; 4; 6; 8; 10|]

let evens = Array.filter (fun x -> x % 2 = 0) numbers
                         // => Array.filter (like List.filter)
                         // => evens is [|10; 2; 4|]

printfn "%A" numbers     // => Outputs: [|10; 2; 3; 4; 5|]
printfn "%A" doubled     // => Outputs: [|20; 4; 6; 8; 10|]

Key Takeaway: Arrays use [| ... |] syntax. Access with .[index], mutate with .[index] <- value.

Why It Matters: Arrays provide O(1) random access critical for algorithms like binary search and numerical computing. While mutable, arrays interoperate seamlessly with .NET libraries and enable zero-copy interop with C libraries, making F# viable for high-performance computing scenarios.

Example 21: Sequences (Lazy Evaluation)

Sequences (seq) are lazily evaluated collections that compute elements on demand, enabling efficient processing of large or infinite data.

// Example 21: Sequences (Lazy Evaluation)
let numbers = seq { 1 .. 10 }
                         // => Sequence expression: seq { start .. end }
                         // => Type: seq<int> (IEnumerable<int>)
                         // => Elements NOT computed yet (lazy)

let evens = numbers
            |> Seq.filter (fun x -> x % 2 = 0)
                         // => Lazy filter: predicate NOT applied yet
            |> Seq.map (fun x -> x * 2)
                         // => Lazy map: function NOT applied yet
                         // => Pipeline builds computation plan

let result = Seq.toList evens
                         // => Forces evaluation: now computes elements
                         // => result is [4; 8; 12; 16; 20]

let infinite = Seq.initInfinite (fun i -> i * i)
                         // => INFINITE sequence: 0, 1, 4, 9, 16, ...
                         // => Lazy evaluation prevents memory overflow

let firstTen = infinite |> Seq.take 10 |> Seq.toList
                         // => Take first 10 elements from infinite sequence
                         // => firstTen is [0; 1; 4; 9; 16; 25; 36; 49; 64; 81]

printfn "%A" result      // => Outputs: [4; 8; 12; 16; 20]
printfn "%A" firstTen    // => Outputs: [0; 1; 4; 9; 16; 25; 36; 49; 64; 81]

Key Takeaway: Sequences evaluate lazily - operations build a computation plan executed when results are consumed.

Why It Matters: Lazy evaluation enables processing infinite sequences and large datasets without loading everything into memory. Streaming log processors use sequences to analyze gigabyte files consuming only kilobytes of RAM, as only the current line is evaluated at any time.

Example 22: Mutable Values (Rare Cases)

F# supports mutable bindings with the mutable keyword for performance-critical scenarios, though immutability is preferred.

// Example 22: Mutable Values (Rare Cases)
let mutable counter = 0  // => mutable keyword allows reassignment
                         // => counter is 0 (type: int)

counter <- counter + 1   // => Reassignment with <-
                         // => counter is now 1

counter <- counter + 1   // => counter is now 2

printfn "%d" counter     // => Outputs: 2

// Prefer immutable approach:
let increment x = x + 1  // => Pure function (no mutation)
let c0 = 0               // => Immutable binding
let c1 = increment c0    // => c1 is 1
let c2 = increment c1    // => c2 is 2

printfn "%d" c2          // => Outputs: 2
                         // => Same result, immutable approach preferred

Key Takeaway: Use mutable sparingly. Prefer immutable values and functions for most code. Mutate only when performance requires it.

Why It Matters: Mutable values enable optimization in tight loops (numerical computing, game engines) where allocation overhead matters. F# developers use mutable state sparingly, isolated to performance hotspots, while keeping business logic purely functional for testability and correctness.

Example 23: For Loops

F# supports imperative for loops for side effects, though functional operations (map, filter, fold) are preferred.

// Example 23: For Loops
let mutable sum = 0      // => Mutable accumulator

for i in 1 .. 5 do       // => for loop: i in range 1 to 5 (inclusive)
    sum <- sum + i       // => Mutation in loop body
                         // => Loop iterations: i=1 (sum=1), i=2 (sum=3), etc.

printfn "Sum: %d" sum    // => Outputs: Sum: 15

// Functional equivalent (preferred):
let functionalSum = [1..5] |> List.sum
                         // => No mutation, List.sum aggregates
                         // => functionalSum is 15

printfn "Functional sum: %d" functionalSum
                         // => Outputs: Functional sum: 15

// For loop with step:
for i in 0 .. 2 .. 10 do // => Step 2: 0, 2, 4, 6, 8, 10
    printfn "%d" i       // => Outputs: 0 2 4 6 8 10 (on separate lines)

Key Takeaway: Use for i in start .. end for imperative loops. Prefer functional operations for transformations.

Why It Matters: For loops with side effects are useful for I/O operations (printing, writing files) where functional abstractions would add complexity without benefit. However, business logic should favor map/filter/fold for testability and composition.

Example 24: While Loops

While loops enable imperative iteration based on conditions, useful for stateful scenarios.

// Example 24: While Loops
let mutable i = 0        // => Mutable loop counter
let mutable sum = 0      // => Mutable accumulator

while i < 5 do           // => while loop: continue while condition true
    sum <- sum + i       // => Accumulate current i
    i <- i + 1           // => Increment counter
                         // => Iterations: i=0 (sum=0), i=1 (sum=1), i=2 (sum=3), etc.

printfn "Sum: %d" sum    // => Outputs: Sum: 10

// Functional equivalent with recursion (preferred):
let rec sumTo n acc =    // => Tail-recursive function
    if n < 0 then acc    // => Base case
    else sumTo (n - 1) (acc + n)
                         // => Recursive case (tail call)

let recursiveSum = sumTo 4 0
                         // => recursiveSum is 10
                         // => Same result, no mutation

printfn "Recursive sum: %d" recursiveSum
                         // => Outputs: Recursive sum: 10

Key Takeaway: While loops require mutable state. Prefer tail recursion for functional iteration without mutation.

Why It Matters: While loops are appropriate for I/O loops (reading until EOF) or interfacing with imperative APIs. F# compilers optimize tail recursion to equivalent while loops, so recursive solutions perform identically without mutable state.

Example 25: If Expressions

F# if is an expression that returns a value, unlike statements in imperative languages.

// Example 25: If Expressions
let x = 10

let description =        // => if expression returns a value
    if x > 0 then        // => Condition: x > 0
        "positive"       // => true branch: returns "positive"
    elif x < 0 then      // => elif for additional conditions
        "negative"       // => returns "negative"
    else                 // => else is REQUIRED (must handle all cases)
        "zero"           // => false branch: returns "zero"
                         // => Type: string
                         // => description is "positive"

printfn "%s" description // => Outputs: positive

// Inline usage:
let abs = if x >= 0 then x else -x
                         // => One-line if expression
                         // => abs is 10

printfn "%d" abs         // => Outputs: 10

Key Takeaway: if is an expression returning values. Both branches must return the same type. else is required.

Why It Matters: Expression-based control flow eliminates uninitialized variables and ensures all paths return values. F# compilers verify branch type consistency, catching errors like returning int from one branch and string from another at compile time.

Example 26: Match Expressions (Advanced)

Match expressions handle complex patterns including guards, when clauses, and nested structures.

// Example 26: Match Expressions (Advanced)
let describe x =         // => Function pattern matching on value
    match x with
    | 0 -> "zero"        // => Literal pattern
    | 1 | 2 | 3 ->       // => OR pattern: matches 1, 2, or 3
        "small"
    | n when n > 0 ->    // => Guard clause: when condition
        "positive"       // => n is bound to the matched value
    | n when n < 0 ->    // => Another guard
        "negative"
    | _ -> "other"       // => Wildcard (unreachable in this example)

printfn "%s" (describe 0)    // => Outputs: zero
printfn "%s" (describe 2)    // => Outputs: small
printfn "%s" (describe 100)  // => Outputs: positive
printfn "%s" (describe -5)   // => Outputs: negative

Key Takeaway: Use when clauses for conditional patterns. OR patterns (|) match multiple cases with the same handler.

Why It Matters: Pattern matching with guards replaces complex if/else chains with declarative case handling. Parsers and compilers use pattern matching extensively to handle syntax variations, reducing bug density compared to equivalent switch statements in C-family languages.

Example 27: Type Annotations

While F# infers types, explicit annotations improve readability and constrain function signatures.

// Example 27: Type Annotations
let add (x: int) (y: int) : int =
                         // => Explicit parameter types: int
                         // => Explicit return type: int
    x + y                // => Function body

let result = add 3 5     // => result is 8

let greet (name: string) : string =
                         // => string parameter, string return
    sprintf "Hello, %s!" name

let message = greet "Alice"
                         // => message is "Hello, Alice!"

// Generic type annotation:
let identity<'T> (x: 'T) : 'T =
                         // => Generic type parameter 'T
                         // => Takes 'T, returns 'T
    x                    // => Returns input unchanged

let num = identity 42    // => Type inference: 'T = int
let str = identity "hi"  // => Type inference: 'T = string

printfn "%d" result      // => Outputs: 8
printfn "%s" message     // => Outputs: Hello, Alice!
printfn "%d %s" num str  // => Outputs: 42 hi

Key Takeaway: Annotate parameters with (param: type), return types with : type after parameters. Use 'T for generic type parameters.

Why It Matters: Explicit types at module boundaries document intent and prevent unintended generalization. Public APIs benefit from type annotations for IntelliSense documentation, while internal functions rely on inference for conciseness.

Example 28: Lambda Expressions (fun)

Lambda expressions (fun) create anonymous functions inline, commonly used with higher-order functions.

// Example 28: Lambda Expressions (fun)
let double = fun x -> x * 2
                         // => Lambda: fun param -> body
                         // => Type: int -> int (inferred)
                         // => Equivalent to: let double x = x * 2

let result = double 5    // => result is 10

// Multi-parameter lambda:
let add = fun x y -> x + y
                         // => Multiple parameters: fun p1 p2 -> body
                         // => Type: int -> int -> int

let sum = add 3 5        // => sum is 8

// Lambdas in higher-order functions:
let doubled = [1; 2; 3] |> List.map (fun x -> x * 2)
                         // => Anonymous function passed to List.map
                         // => doubled is [2; 4; 6]

// Lambda with pattern matching:
let describeOpt = fun opt ->
    match opt with
    | Some v -> sprintf "Value: %d" v
    | None -> "No value"

printfn "%s" (describeOpt (Some 42))
                         // => Outputs: Value: 42

printfn "%d" result      // => Outputs: 10
printfn "%A" doubled     // => Outputs: [2; 4; 6]

Key Takeaway: Lambdas use fun param -> body syntax. They’re expressions that create function values.

Why It Matters: Lambdas enable inline function definitions without naming overhead. Data processing pipelines use lambdas extensively: data |> filter (fun x -> x.IsValid) |> map (fun x -> x.Price) reads naturally without defining separate named functions.

Example 29: Partial Application

F# functions are curried by default - multi-parameter functions can be partially applied to create specialized functions.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
graph TD
    A[add: int -> int -> int]:::blue --> B[addFive = add 5<br/>int -> int]:::orange
    B --> C[addFive 3<br/>Returns 8]:::teal

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#DE8F05,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff

Code:

// Example 29: Partial Application
let add x y = x + y      // => Function with 2 parameters
                         // => Type: int -> int -> int (curried)

let addFive = add 5      // => Partial application: fix x = 5
                         // => addFive has type: int -> int
                         // => Waiting for y parameter

let result = addFive 3   // => Apply remaining parameter y = 3
                         // => result is 8 (5 + 3)

// Partial application with List.map:
let multiply x y = x * y // => Type: int -> int -> int

let multiplyByTwo = multiply 2
                         // => Fix first parameter: x = 2
                         // => Type: int -> int

let doubled = [1; 2; 3; 4; 5] |> List.map multiplyByTwo
                         // => Pass partially applied function
                         // => doubled is [2; 4; 6; 8; 10]

printfn "%d" result      // => Outputs: 8
printfn "%A" doubled     // => Outputs: [2; 4; 6; 8; 10]

Key Takeaway: Applying fewer arguments than a function takes creates a new function waiting for remaining arguments.

Why It Matters: Partial application enables function specialization without wrapper functions. Configuration functions like multiply taxRate amount can be partially applied to multiplyByTaxRate = multiply 0.08 and reused throughout a codebase, improving code reuse.

Example 30: Currying and Higher-Order Functions

Higher-order functions take functions as parameters or return functions, enabling powerful abstractions.

// Example 30: Currying and Higher-Order Functions
let apply f x = f x      // => Higher-order function: takes function f and value x
                         // => Applies f to x
                         // => Type: ('a -> 'b) -> 'a -> 'b (generic)

let double x = x * 2     // => Function to pass

let result = apply double 5
                         // => apply double 5 = double 5 = 10
                         // => result is 10

// Function returning function:
let makeAdder n =        // => Takes int, returns function
    fun x -> x + n       // => Returns lambda capturing n
                         // => Type: int -> (int -> int)

let addTen = makeAdder 10
                         // => addTen is function adding 10
                         // => Type: int -> int

let value = addTen 5     // => value is 15

// Composing higher-order functions:
let compose f g =        // => Function composition
    fun x -> f (g x)     // => Returns lambda applying g then f
                         // => Type: ('b -> 'c) -> ('a -> 'b) -> ('a -> 'c)

let addOne x = x + 1     // => int -> int
let multiplyByTwo x = x * 2
                         // => int -> int

let addOneThenDouble = compose multiplyByTwo addOne
                         // => Compose: double(addOne(x))
                         // => Type: int -> int

let composed = addOneThenDouble 5
                         // => addOne 5 = 6, multiplyByTwo 6 = 12
                         // => composed is 12

printfn "%d" result      // => Outputs: 10
printfn "%d" value       // => Outputs: 15
printfn "%d" composed    // => Outputs: 12

Key Takeaway: Higher-order functions abstract over behavior. Functions can capture values (closures) and return specialized functions.

Why It Matters: Higher-order functions enable generic algorithms and design patterns. Dependency injection frameworks use higher-order functions to wire components: makeService config = fun request -> processWithConfig config request, creating configured services without mutable global state.


Next Steps

Continue to Intermediate (Examples 31-60) to learn:

  • Advanced pattern matching and active patterns
  • Object-oriented features (classes, interfaces)
  • Async/await for asynchronous programming
  • Computation expressions (monads)
  • Type providers for data access
  • Interop with C# and .NET libraries

These 30 beginner examples cover 0-40% of F#’s features, establishing the functional-first foundation. The remaining 60% (intermediate and advanced) builds on these fundamentals with production patterns and advanced type system features.

Last updated