F# variable scope
last modified May 17, 2025
In this tutorial, we explore variable scope in F# and how it affects code organization and accessibility.
Variable scope in F# defines where a binding can be accessed, ensuring clarity and predictability in code execution. F# employs lexical scoping, meaning a variable's scope is determined by the structure of the code rather than runtime conditions. Since F# follows an indentation-based block structure, bindings exist only within the defined scope, preventing unintended modifications or conflicts.
Additionally, F# emphasizes immutability by default, encouraging safer and more predictable programming. While values can be modified using mutable bindings when necessary, maintaining strict scoping practices enhances code reliability and minimizes unexpected behavior. Understanding how scope interacts with functions, modules, and pattern matching is key to writing maintainable F# programs.
Basic let bindings
The most common way to declare variables in F# is with let bindings.
These are scoped to the block where they're defined and can't be accessed
outside that block.
let outerValue = 10
let calculate x =
let innerValue = 5
x + innerValue + outerValue
printfn "Result: %d" (calculate 7)
// This would cause an error - innerValue not in scope
// printfn "Inner: %d" innerValue
This example shows an outerValue accessible throughout the module
and an innerValue only accessible within the calculate
function. Trying to access innerValue outside its scope is an error.
λ dotnet fsi basic_scope.fsx Result: 22
Nested scopes
Inner scopes can access bindings from outer scopes, but outer scopes can't access inner bindings. Each new block creates a new scope level.
let moduleLevel = "Module level"
let outerFunction () =
let outerVar = "Outer function"
let innerFunction () =
let innerVar = "Inner function"
printfn "%s, %s, %s" moduleLevel outerVar innerVar
innerFunction()
// This would error - innerVar not accessible here
// printfn "%s" innerVar
outerFunction()
This demonstrates three scope levels: module, outer function, and inner function. Each inner scope can access bindings from all surrounding outer scopes, but not vice versa.
λ dotnet fsi nested_scopes.fsx Module level, Outer function, Inner function
Shadowing bindings
F# allows shadowing - declaring a new binding with the same name as an outer one. This creates a new binding that hides the outer one in its scope.
let value = 10
let shadowExample () =
printfn "Original: %d" value
let value = "Hello" // Shadows the outer value
printfn "Shadowed: %s" value
let value = 3.14 // Shadows again
printfn "Double shadow: %f" value
shadowExample()
// Outer binding remains unchanged
printfn "After shadowing: %d" value
Shadowing creates new bindings rather than modifying existing ones. The original binding remains unchanged outside the shadowing scope.
λ dotnet fsi shadowing.fsx Original: 10 Shadowed: Hello Double shadow: 3.140000 After shadowing: 10
Function parameters
Function parameters have scope limited to the function body. They behave like let bindings declared at the start of the function.
let calculateTotal price quantity =
let discount =
if quantity > 10 then 0.1
else 0.0
let subtotal = price * float quantity
subtotal - (subtotal * discount)
// Parameters price and quantity only exist in calculateTotal
printfn "Total: %.2f" (calculateTotal 25.99 15)
The price and quantity parameters are scoped to
the calculateTotal function, just like the discount
and subtotal bindings declared inside it.
λ dotnet fsi parameter_scope.fsx Total: 350.87
Module scope
Bindings at the module level are accessible to all code in that module and can be made available to other modules via accessibility modifiers.
module MyModule =
let publicValue = "I'm public"
let private privateValue = "I'm private"
let printValues () =
printfn "%s" publicValue
printfn "%s" privateValue
// Can access public module members
printfn "%s" MyModule.publicValue
MyModule.printValues()
// This would error - privateValue not accessible
// printfn "%s" MyModule.privateValue
This shows module-level scoping with accessibility control.
publicValue is accessible outside the module, while
privateValue is only accessible within the module.
λ dotnet fsi module_scope.fsx I'm public I'm public I'm private
Local functions
Functions can be defined inside other functions, limiting their scope to the containing function. These can access bindings from their parent scope.
let calculateStatistics numbers =
let sumValues list =
List.fold (fun acc x -> acc + x) 0 list
let average list =
let sum = sumValues list
float sum / float (List.length list)
printfn "Sum: %d" (sumValues numbers)
printfn "Average: %.2f" (average numbers)
calculateStatistics [1..10]
// These would error - local functions not accessible
// sumValues [1;2;3]
// average [1;2;3]
The sumValues and average functions are only
accessible within calculateStatistics. They can access the
numbers parameter from their parent scope.
λ dotnet fsi local_functions.fsx Sum: 55 Average: 5.50
Scope in computation expressions
Computation expressions like async and seq have their own scope rules.
The let! and use! bindings have special scoping.
let asyncExample =
async {
let outer = 10
let! inner = async { return 20 }
return outer + inner
}
let result = Async.RunSynchronously asyncExample
printfn "Async result: %d" result
let seqExample =
seq {
let x = 1
yield x
let x = x + 1 // Shadows previous x
yield x
yield! seq { yield x + 1; yield x + 2 }
}
printfn "Sequence: %A" (Seq.toList seqExample)
This shows scoping in computation expressions. The async block has access to its outer bindings, and the seq block demonstrates shadowing within a sequence expression.
λ dotnet fsi computation_scopes.fsx Async result: 30 Sequence: [1; 2; 3; 4]
This tutorial covered the basics of variable scope in F#. We explored how scope works with let bindings, modules, and nested functions. We also discussed shadowing, function parameters, and the scope of computation expressions. By understanding these concepts, you can write more organized and maintainable F# code.