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 default —
let 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
Comments
Loading comments...
Leave a Comment