Beginner

Variables and Types in F#

Learn about let bindings, immutability, type inference, and the F# type system with practical Docker-ready examples

In most programming languages, variables are containers you assign values to and change freely. F# takes a different approach. As a functional-first language, F# uses let bindings that are immutable by default — once you bind a name to a value, it stays that way. This isn’t a limitation; it’s a design choice that leads to more predictable, easier-to-reason-about code.

F# also features one of the most powerful type inference engines of any mainstream language. Inherited from its ML-family roots, the F# compiler can figure out the types of your values without you writing a single type annotation. You get the safety of static typing with the feel of a dynamically typed language.

In this tutorial, you’ll learn how F# handles value bindings, explore the core primitive types, see how type inference works in practice, and understand when and how to use mutable variables.

Let Bindings and Basic Types

F# uses let to bind names to values. Unlike variable assignment in imperative languages, a let binding creates an immutable association between a name and a value.

Create a file named variables.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// --- Let Bindings: Immutable by Default ---
let age = 30
let pi = 3.14159
let name = "F#"
let isFunctional = true
let initial = 'F'

printfn "--- Basic Types ---"
printfn "Integer:   %d" age
printfn "Float:     %f" pi
printfn "String:    %s" name
printfn "Boolean:   %b" isFunctional
printfn "Character: %c" initial

// --- Type Annotations ---
// The compiler infers types, but you can annotate explicitly
let year: int = 2005
let version: float = 9.0
let creator: string = "Don Syme"

printfn ""
printfn "--- Type Annotations ---"
printfn "%s created F# in %d (now version %.1f)" creator year version

// --- Type Inference in Action ---
// The compiler infers the return type from usage
let doubled = age * 2
let greeting = "Hello, " + name
let area = pi * 5.0 * 5.0

printfn ""
printfn "--- Type Inference ---"
printfn "Doubled age: %d" doubled
printfn "Greeting: %s" greeting
printfn "Circle area: %.2f" area

// --- Numeric Types ---
let byteVal: byte = 255uy
let shortVal: int16 = 32_000s
let longVal: int64 = 9_000_000_000L
let decimalVal: decimal = 19.99M
let float32Val: float32 = 2.5f

printfn ""
printfn "--- Numeric Types ---"
printfn "Byte:    %d" byteVal
printfn "Int16:   %d" shortVal
printfn "Int64:   %d" longVal
printfn "Decimal: %M" decimalVal
printfn "Float32: %f" float32Val

// --- Type Conversions ---
let intFromFloat = int 3.7
let floatFromInt = float 42
let stringFromInt = string 100
let intFromString = int "255"

printfn ""
printfn "--- Type Conversions ---"
printfn "int 3.7      = %d" intFromFloat
printfn "float 42     = %f" floatFromInt
printfn "string 100   = %s" stringFromInt
printfn "int \"255\"    = %d" intFromString

Immutability, Mutability, and Option Types

F# strongly favors immutability, but provides mutable for cases where you need it. F# also replaces null with the Option type — a safer way to represent values that might not exist.

Create a file named variables_advanced.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// --- Immutability ---
let x = 10
// x <- 20  // This would cause a compiler error!

// Shadowing: you can rebind a name with a new let
let x = x + 5
let x = x * 2
printfn "--- Immutability and Shadowing ---"
printfn "x after shadowing: %d" x

// --- Mutable Variables ---
// Use 'mutable' when you truly need to change a value
let mutable counter = 0
printfn ""
printfn "--- Mutable Variables ---"
printfn "Counter start: %d" counter
counter <- counter + 1
counter <- counter + 1
counter <- counter + 1
printfn "Counter after 3 increments: %d" counter

// --- Option Type (replacing null) ---
printfn ""
printfn "--- Option Type ---"

let findUser (id: int) : string option =
    if id = 1 then Some "Alice"
    elif id = 2 then Some "Bob"
    else None

let showUser id =
    match findUser id with
    | Some name -> printfn "User %d: %s" id name
    | None -> printfn "User %d: not found" id

showUser 1
showUser 2
showUser 99

// --- Tuples ---
printfn ""
printfn "--- Tuples ---"
let point = (3, 4)
let person = ("Grace Hopper", 1906)

let (xCoord, yCoord) = point
let (personName, birthYear) = person

printfn "Point: (%d, %d)" xCoord yCoord
printfn "Person: %s, born %d" personName birthYear

// --- Constants ---
printfn ""
printfn "--- Literal Constants ---"
[<Literal>]
let MaxRetries = 5

[<Literal>]
let AppName = "CodeArchaeology"

printfn "App: %s (max retries: %d)" AppName MaxRetries

Running with Docker

1
2
3
4
5
6
7
8
# Pull the official .NET SDK image
docker pull mcr.microsoft.com/dotnet/sdk:9.0

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

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

Expected Output

Output from variables.fsx:

--- Basic Types ---
Integer:   30
Float:     3.141590
String:    F#
Boolean:   true
Character: F

--- Type Annotations ---
Don Syme created F# in 2005 (now version 9.0)

--- Type Inference ---
Doubled age: 60
Greeting: Hello, F#
Circle area: 78.54

--- Numeric Types ---
Byte:    255
Int16:   32000
Int64:   9000000000
Decimal: 19.99
Float32: 2.500000

--- Type Conversions ---
int 3.7      = 3
float 42     = 42.000000
string 100   = 100
int "255"    = 255

Output from variables_advanced.fsx:

--- Immutability and Shadowing ---
x after shadowing: 30

--- Mutable Variables ---
Counter start: 0
Counter after 3 increments: 3

--- Option Type ---
User 1: Alice
User 2: Bob
User 99: not found

--- Tuples ---
Point: (3, 4)
Person: Grace Hopper, born 1906

--- Literal Constants ---
App: CodeArchaeology (max retries: 5)

Key Concepts

  • Immutable by defaultlet bindings cannot be reassigned; use mutable and <- only when necessary
  • Type inference — The F# compiler deduces types from context, so explicit annotations are rarely needed
  • Type annotations — Add them with let name: type = value when you want clarity or need to resolve ambiguity
  • Shadowing vs mutation — Rebinding a name with a new let creates a new binding rather than changing the original value
  • Option type — F# uses Some and None instead of null, making missing values explicit and safe to handle via pattern matching
  • Numeric suffixes — Literals like 255uy, 32_000s, 9L, 19.99M, and 2.5f specify exact numeric types
  • Tuples — Group related values together with parentheses; destructure them with pattern matching
  • Literal constants — The [<Literal>] attribute creates true compile-time constants

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