ZetCode

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.

basic_scope.fsx
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.

nested_scopes.fsx
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.

shadowing.fsx
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.

parameter_scope.fsx
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_scope.fsx
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.

local_functions.fsx
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.

computation_scopes.fsx
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.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.