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.