F# Module 3 – Functions and functional style
Lambdas, closures and higher-order functions
Write F# lambdas, pass and return functions, price tickets with a Map of functions, see what a closure captures and retire the old reference cell operators.
What you will learn
- Write lambda expressions and pass functions as arguments and return values
- Build a dispatch table, a Map of functions, in place of a long match
- Explain what a closure captures and how F# handles a captured mutable local
- Replace the reference-cell operators deprecated in F# 6 with .Value
Before you start
What you’ll learn · Before you start
What you will learn
- Write lambda expressions and pass functions as arguments and return values
- Build a dispatch table, a Map of functions, in place of a long match
- Explain what a closure captures and how F# handles a captured mutable local
- Replace the reference-cell operators deprecated in F# 6 with .Value
Before you start
On this page
fun x -> x * 2 is a complete function with no name and no return, and F# lets you store it in a list, pass it to
List.map or return it from another function. Below, a Map of four pricing functions handles four tickets, and not
one line of the pricing code names a rule.
You will write lambdas, pass and return functions, build that dispatch table: A lookup table from keys to functions, such as a Map from a rule's name to the function that applies it, used instead of a long match: adding a case means adding an entry., see exactly what a closure keeps, including the one case where three closures share a value, and replace the reference cell operators that F# 6 deprecated. Five sessions recorded on .NET 10 back every rule.
A lambda is a function without a name
A lambda expression: An unnamed function written with fun, its parameters, an arrow and a body, such as fun x -> x * 2. It is a value: it can be passed, returned, stored in a list or bound to a name with let. is written fun, its parameters, an arrow and a body. Here
are lambdas bound to a name, passed straight to library functions, and kept in a list:
from session import fsi
# Lambdas bound to a name, passed straight to List.map and List.filter, and kept in a list like any other value.
fsi([
"let double = fun x -> x * 2;;",
"[ 1; 2; 3 ] |> List.map double;;",
"[ 1; 2; 3 ] |> List.map (fun x -> x * x);;",
'let adults = [ "Asha", 34; "Kiran", 15 ] |> List.filter (fun (_, age) -> age >= 18);;',
"let steps = [ double; (fun x -> x + 1); (fun x -> x - 3) ];;",
"steps |> List.map (fun f -> f 100);;",
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi --nologo
> let double = fun x -> x * 2;;
val double: x: int -> int
> [ 1; 2; 3 ] |> List.map double;;
val it: int list = [2; 4; 6]
> [ 1; 2; 3 ] |> List.map (fun x -> x * x);;
val it: int list = [1; 4; 9]
> let adults = [ "Asha", 34; "Kiran", 15 ] |> List.filter (fun (_, age) -> age >= 18);;
val adults: (string * int) list = [("Asha", 34)]
> let steps = [ double; (fun x -> x + 1); (fun x -> x - 3) ];;
val steps: (int -> int) list =
[<fun:steps@5>; <fun:steps@5-1>; <fun:steps@5-2>]
> steps |> List.map (fun f -> f 100);;
val it: int list = [200; 101; 97]
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lambdas_session.py
let double = fun x -> x * 2 printed the same signature that let double x = x * 2 would, x: int -> int: the
two forms define the same function. Passed to List.map, a lambda saves you from naming a function you need once,
which is the use the Lambda Expressions reference gives for them. The parameter list can be any pattern, so
fun (_, age) -> age >= 18 takes a tuple apart and ignores its first item.
The last two entries show that a function is a value like any other. steps holds double and two lambdas, all of
type int -> int, and F# Interactive printed them as <fun:…> because a function has no printed form. Step through
what List.map did with them:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Functions held in a list. steps holds a named function and two lambdas. They all have the type int -> int, so they fit in one list, which F# Interactive printed as three <fun:…> values because a function has no printed form.
- The first item is double. List.map hands each item to fun f -> f 100, so f is a function here. For the first item the lambda calls double 100, which is 200.
- The second item. The second item is fun x -> x + 1, a function without a name. Called with 100, it gives 101.
- The third item. The third, fun x -> x - 3, gives 97. List.map treats the three functions exactly as it would treat three numbers.
- The result. The session printed val it: int list = [200; 101; 97]: one result for each function, in the order of the list.
In fun f -> f 100, the parameter f is itself a function. Nothing about List.map changes: it calls the lambda on
each item, whatever the items are.
Name a lambda when it grows
A lambda of one line belongs where it is used. When it needs several lines or a comment, give it a name with let:
the name says what the step does, and the pipeline that uses it stays readable.
Checkpoint
Check: Lambdas and functions as values
Functions go in, and functions come out
A higher-order function: A function that takes another function as an argument, returns one as its result, or both, such as List.map, which takes the function to apply to each item. takes a function as an argument, returns one as its result, or both. The Using functions tutorial lists both among what makes functions first-class values in F#:
from session import fsi, run
# A function that takes a function, a function that returns one, then fares.fsx, a Map of functions.
fsi([
"let applyTwice f x = f (f x);;",
"applyTwice (fun x -> x * 2) 5;;",
"let makeDiscount percent = fun price -> price - price * percent / 100;;",
"let tenOff = makeDiscount 10;;",
"tenOff 500;;",
"applyTwice tenOff 500;;",
"#quit;;",
], args=["--nologo"])
run("""
dotnet fsi fares.fsx
""") Output
$ dotnet fsi --nologo
> let applyTwice f x = f (f x);;
val applyTwice: f: ('a -> 'a) -> x: 'a -> 'a
> applyTwice (fun x -> x * 2) 5;;
val it: int = 20
> let makeDiscount percent = fun price -> price - price * percent / 100;;
val makeDiscount: percent: int -> price: int -> int
> let tenOff = makeDiscount 10;;
val tenOff: (int -> int)
> tenOff 500;;
val it: int = 450
> applyTwice tenOff 500;;
val it: int = 405
> #quit;;
$ dotnet fsi fares.fsx
Asha senior 480.00
Ravi student 600.00
Meera child 400.00
Dev pilgrim refused: no rule called pilgrim
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 higher_session.py
applyTwice f x = f (f x) takes a function f and calls it twice, so its signature starts with f: ('a -> 'a): a
function from a type to the same type, because the result goes back in. makeDiscount 10 returns a function, which
F# Interactive printed as val tenOff: (int -> int), and applyTwice tenOff 500 took 10 % off twice: 500, then 450,
then 405. The FSharp.Core collection functions follow the same order: List.map f xs takes the function first and
the list last, so they partially apply and pipe well.
Functions as values also replace a common C# shape, a switch over names. fares.fsx keeps its rules in a Map
from a rule’s name to the function that prices it, and price looks the rule up with Map.tryFind:
type Ticket = { Passenger: string; Rule: string; Fare: decimal }
// A dispatch table: each rule's name maps to the function that prices it.
let rules: Map<string, decimal -> decimal> =
Map [ "standard", id
"senior", fun fare -> fare * 0.6m
"student", fun fare -> fare * 0.75m
"child", fun fare -> fare / 2m ]
let price ticket =
match Map.tryFind ticket.Rule rules with
| Some rule -> Ok(rule ticket.Fare)
| None -> Error $"no rule called {ticket.Rule}"
let tickets =
[ { Passenger = "Asha"; Rule = "senior"; Fare = 800m }
{ Passenger = "Ravi"; Rule = "student"; Fare = 800m }
{ Passenger = "Meera"; Rule = "child"; Fare = 800m }
{ Passenger = "Dev"; Rule = "pilgrim"; Fare = 800m } ]
for t in tickets do
match price t with
| Ok fare -> printfn "%-5s %-8s %7.2f" t.Passenger t.Rule fare
| Error message -> printfn "%-5s %-8s refused: %s" t.Passenger t.Rule message The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The table. rules is a Map from a rule's name to the function that prices it, of type Map<string, decimal -> decimal>. Its entries are standard, senior, student and child; three of them are drawn here.
- Asha, senior. Asha's ticket names senior. Map.tryFind finds the function stored under that key, and price applies it to the fare of 800, which gives 480.00.
- Ravi, student. The same two lines of price handle Ravi. Only the key differs, so a different function runs, and 800 becomes 600.00.
- Meera, child. Meera's child rule halves the fare, 400.00. No match expression lists the rules; the Map holds them as data.
- Dev, an unknown rule. Dev's ticket names pilgrim, which is not a key of the Map. Map.tryFind returns None, and price returns Error "no rule called pilgrim" instead of throwing an exception.
- A new rule is a new entry. To add a rule, add one entry to the Map, a name and a function. The code of price does not change, which is what a dispatch table is for.
Asha’s senior fare of 800 became 480.00 and Ravi’s student fare 600.00, through the same two lines of price. Dev’s
pilgrim rule is not a key of the Map, so Map.tryFind returned None and price returned an Error instead of
throwing. Adding a rule means adding one entry; price does not change.
Lambdas in hot loops
A function value is an object: the F# component design guidelines note that C# sees it as an instance of
FSharpFunc. Where that matters, the [<InlineIfLambda>] attribute on a parameter of an inline function asks
the compiler to inline a lambda passed to it; the FSharp.Core documentation warns that this can make the code much
larger when the parameter is called several times. The performance lesson of this track comes back to it.
Checkpoint
Check: Higher-order functions and dispatch tables
A closure keeps what it captured
A lambda can use values from the place where it was created. The function together with those values is a closure: A function value together with the values it uses from the scope where it was created. A captured immutable value stays as it was; a captured let mutable local is shared through a reference cell.. Two programs show what a closure keeps, first with counters, then with closures made in loops:
// Each call of makeCounter makes a new count, and the function it returns keeps that count.
let makeCounter () =
let mutable count = 0
fun () ->
count <- count + 1
count
let a = makeCounter ()
let b = makeCounter ()
printfn "a: %d, a: %d, b: %d, a: %d" (a ()) (a ()) (b ()) (a ()) // Three closures from a for loop: each pass binds a new i, and each closure keeps its own.
let fromFor = [ for i in 1..3 -> fun () -> i ]
// Three closures from a while loop over one let mutable: all three share it.
let fromWhile =
let mutable i = 0
let made = ResizeArray()
while i < 3 do
i <- i + 1
made.Add(fun () -> i)
List.ofSeq made
// The fix: copy the current value into a new immutable name before capturing it.
let fromWhileFixed =
let mutable i = 0
let made = ResizeArray()
while i < 3 do
i <- i + 1
let current = i
made.Add(fun () -> current)
List.ofSeq made
printfn "for loop: %A" (fromFor |> List.map (fun f -> f ()))
printfn "while, shared i: %A" (fromWhile |> List.map (fun f -> f ()))
printfn "while, copied: %A" (fromWhileFixed |> List.map (fun f -> f ())) from session import run
# Two counters, then closures made in a for loop and in a while loop, with and without warning 3180.
run("""
dotnet fsi counters.fsx
dotnet fsi capture.fsx
dotnet fsi --warnon:3180 capture.fsx
""") Output
$ dotnet fsi counters.fsx a: 1, a: 2, b: 1, a: 3 $ dotnet fsi capture.fsx for loop: [1; 2; 3] while, shared i: [3; 3; 3] while, copied: [1; 2; 3] $ dotnet fsi --warnon:3180 capture.fsx capture.fsx(6,17): warning FS3180: The mutable local 'i' is implicitly allocated as a reference cell because it has been captured by a closure. This warning is for informational purposes only to indicate where implicit allocations are performed. for loop: [1; 2; 3] while, shared i: [3; 3; 3] while, copied: [1; 2; 3]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 closures_session.py
Each call of makeCounter creates a new count, so a and b count separately: a printed 1, 2 and 3, and b
printed 1 in between. count is a let mutable local that must outlive the call that created it, and the Values
reference explains how: a mutable value captured by a closure may be promoted to a
reference cell: A small heap object that holds one value that can be changed, created with ref and read or written through .Value. The compiler creates one when a closure captures a let mutable local. More in “Values, expressions and immutability”, and warning 3180, off by default, reports where. The values lesson
of this track stepped through one such counter; the loops of capture.fsx show what the cell means when several
closures capture the same variable:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Each for pass binds a new i. In [ for i in 1..3 -> fun () -> i ], every pass of the loop binds a new immutable value named i, and the lambda made in that pass captures that value.
- The for closures. Calling the three closures printed [1; 2; 3]. Each kept the value it captured, and nothing can change an immutable value afterwards.
- One mutable i. The while loop changes one let mutable i. Closures that use it need it to outlive the loop, so the compiler stores it in a reference cell, and --warnon:3180 reported FS3180 at line 6.
- Every closure reads the cell. Each pass adds a closure, fun () -> i. None of them copies i; all three refer to the one cell, which the loop keeps changing until it holds 3.
- The shared value. After the loop each call reads the cell and finds 3: the script printed [3; 3; 3]. The closures were made in different passes, but they share one variable.
- The fix, a copy per pass. In fromWhileFixed each pass binds let current = i, an immutable copy, and the closure captures current instead of i. The calls printed [1; 2; 3], as the for loop did.
The for loop binds a new i in every pass, so its three closures kept 1, 2 and 3. The while loop has one mutable
i, now one cell, and its three closures all read that cell after the loop had set it to 3: [3; 3; 3].
--warnon:3180 pointed at line 6, the declaration of that i, and at no line of the fixed loop, whose closures
capture current, an immutable copy made in each pass.
Capturing a variable the loop keeps changing
Wrong: made.Add(fun () -> i) inside a while loop over let mutable i, which made three closures that all
returned 3. Right: let current = i inside the loop, then fun () -> current, which returned 1, 2 and 3; or a
for … in loop, which binds a new value in every pass.
Sharing is not always a bug: makeCounter depends on it. It becomes one when each closure was meant to keep a value
of its own.
Key idea
A closure keeps the values it captured; when it captures a let mutable local, it keeps the cell that holds it, so every closure made in that scope sees the variable’s latest value.
Checkpoint
Check: What a closure captures
Write .Value, not the old reference cell operators
A reference cell is the same box the compiler made for count, created by hand with ref. The Reference Cells
reference describes it as a storage location for a mutable value with reference semantics, read and written through
its Value property. Older code uses operators for that:
from session import fsi
# A reference cell written with the old operators, each of which gives informational message FS3370, then .Value.
fsi([
"let hits = ref 0;;",
"hits := !hits + 1;;",
"incr hits;;",
"decr hits;;",
"hits.Value <- hits.Value + 1;;",
"hits.Value;;",
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi --nologo
> let hits = ref 0;;
val hits: int ref = { contents = 0 }
> hits := !hits + 1;;
hits := !hits + 1;;
-----^^
stdin(2,6): info FS3370: The use of ':=' from the F# library is deprecated. See https://aka.ms/fsharp-refcell-ops. For example, please change 'cell := expr' to 'cell.Value <- expr'.
hits := !hits + 1;;
--------^
stdin(2,9): info FS3370: The use of '!' from the F# library is deprecated. See https://aka.ms/fsharp-refcell-ops. For example, please change '!cell' to 'cell.Value'.
val it: unit = ()
> incr hits;;
incr hits;;
^^^^
stdin(3,1): info FS3370: The use of 'incr' from the F# library is deprecated. See https://aka.ms/fsharp-refcell-ops. For example, please change 'incr cell' to 'cell.Value <- cell.Value + 1'.
val it: unit = ()
> decr hits;;
decr hits;;
^^^^
stdin(4,1): info FS3370: The use of 'decr' from the F# library is deprecated. See https://aka.ms/fsharp-refcell-ops. For example, please change 'decr cell' to 'cell.Value <- cell.Value - 1'.
val it: unit = ()
> hits.Value <- hits.Value + 1;;
val it: unit = ()
> hits.Value;;
val it: int = 2
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 refcells_session.py
Each old operator still worked, and each earned informational message FS3370, which names its replacement:
| Old form | Message FS3370 suggests | Does |
|---|---|---|
!hits |
hits.Value |
reads the value |
hits := v |
hits.Value <- v |
writes a new value |
incr hits |
hits.Value <- hits.Value + 1 |
adds one |
decr hits |
hits.Value <- hits.Value - 1 |
takes one away |
The What’s new in F# 6 page gives the reasons: reference cells are rarely needed because let mutable covers most
uses, fewer operators make F# simpler to learn, and ! is easy to confuse with the not of C#. The messages are
informational, not warnings, so old code keeps compiling; RFC FS-1111 also lists --nowarn:3370 for code that
cannot be changed yet.
Reach for let mutable first
The F# coding conventions prefer let mutable to ref: a cell spreads .Value through every line that touches it,
while a mutable local reads like any other value. Use a cell when the box itself must be passed around.
Checkpoint
Check: Reference cells and .Value
Practise: memoize a slow lookup
memoize f returns a function with the same signature as f that remembers its results, which is called
memoization: Saving the result of a function for each argument it has seen, so that a repeated call returns the saved result instead of computing it again.. The cache is a Dictionary that only the returned closure can reach, so
each call of memoize makes a cache of its own. Read the script, predict the counts it prints, then compare:
open System.Collections.Generic
// memoize returns a function with the same signature as f. The Dictionary lives in the closure,
// so each memoized function has a cache of its own.
let memoize (f: 'a -> 'b) =
let cache = Dictionary<'a, 'b>()
fun x ->
match cache.TryGetValue x with
| true, saved -> saved
| false, _ ->
let result = f x
cache[x] <- result
result
// A slow lookup, standing in for a database or a web call: it counts how often it runs.
let mutable lookups = 0
let fareFrom city =
lookups <- lookups + 1
match city with
| "Pune" -> 450
| "Agra" -> 620
| _ -> failwith $"no fare from {city}"
let cachedFare = memoize fareFrom
let trips = [ "Pune"; "Agra"; "Pune"; "Pune"; "Agra" ]
printfn "fares: %A" (trips |> List.map cachedFare)
printfn "lookups for %d trips: %d" trips.Length lookups
// A second memoized function starts with an empty cache of its own.
let otherCache = memoize fareFrom
otherCache "Pune" |> ignore
printfn "lookups after a second cache: %d" lookups
// A failure is not saved, so the next call tries again.
for _ in 1..2 do
try
cachedFare "Goa" |> ignore
with ex ->
printfn "failed: %s (lookups: %d)" ex.Message lookups from session import fsi
# #load runs memo.fsx, then prints the signature of memoize and of the functions it made.
fsi([
'#load "memo.fsx";;',
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi --nologo
> #load "memo.fsx";;
[Loading memo.fsx]
fares: [450; 620; 450; 450; 620]
lookups for 5 trips: 2
lookups after a second cache: 3
failed: no fare from Goa (lookups: 4)
failed: no fare from Goa (lookups: 5)
module FSI_0002.Memo
val memoize: f: ('a -> 'b) -> ('a -> 'b) when 'a: equality
val mutable lookups: int
val fareFrom: city: string -> int
val cachedFare: (string -> int)
val trips: string list
val otherCache: (string -> int)
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 memo_session.py
Five trips needed only two lookups, one for Pune and one for Agra. A second memoized function, otherCache, has its
own empty Dictionary, so its first call ran the lookup again, the third. The lookup for Goa failed twice and ran
twice, because cache[x] <- result is never reached when f x throws: failures are not cached. The signature,
f: ('a -> 'b) -> ('a -> 'b) when 'a: equality, carries a condition that the Dictionary key brings: the key type
must support equality.
Share one cache between both functions
Move the cache out of memoize: write let cache = Dictionary<string, int>() above it and delete the let cache
line inside, then run dotnet fsi memo.fsx. The line after the second cache now says 2, because both memoized
functions share one Dictionary, and the Goa lines say 3 and 4.
Then try the four ideas of this lesson in the self-check. It begins where your answers so far are weakest, and each option, right or wrong, comes with the reason the moment you choose it:
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what does an F# closure capture, and when do two closures share a value?
A closure is a function value together with the values it uses from the scope where it was created. For an
immutable value it captures that value, so later code cannot change what the closure sees: three lambdas made in
for i in 1..3 kept 1, 2 and 3. A let mutable local is different. A closure that uses it needs it to outlive its
scope, so the compiler stores it in a reference cell (warning 3180 reports where), and the closure keeps the cell.
Every closure made over that variable shares the cell and sees its latest value: three lambdas made in a while loop
over one mutable i all returned 3. Each call of a function such as makeCounter creates a new cell, so separate
counters stay separate.
Two harder questions follow, about callbacks that all report the same value and about rules kept in a long match:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A fare engine has 30 discount rules in one long match over rule names, and product owners add a rule every month. Would you move the rules into a Map of functions, keep the match, or model them as a union? Defend your choice.
Key takeaways
fun x -> x * 2is a function value without a name; it can be passed, returned, stored in a list or bound withlet, exactly like a function defined withlet.- A higher-order function takes or returns functions, and a
Mapof names to functions is a dispatch table: adding a rule is adding an entry, andMap.tryFindhandles a name that has no rule. - A closure keeps the immutable values it captured, while a captured
let mutablelocal becomes one reference cell that every closure over it shares, which is why closures made in awhileloop all saw 3. - Write
cell.Valueandcell.Value <- vinstead of!,:=,incranddecr, which give informational message FS3370 since F# 6, and preferlet mutabletorefwhere a local will do.
Terms in this lesson
- closure
- A function value together with the values it uses from the scope where it was created. A captured immutable value stays as it was; a captured let mutable local is shared through a reference cell.
- dispatch table
- A lookup table from keys to functions, such as a Map from a rule's name to the function that applies it, used instead of a long match: adding a case means adding an entry.
- higher-order function
- A function that takes another function as an argument, returns one as its result, or both, such as List.map, which takes the function to apply to each item.
- lambda expression
- An unnamed function written with fun, its parameters, an arrow and a body, such as fun x -> x * 2. It is a value: it can be passed, returned, stored in a list or bound to a name with let.
- memoization
- Saving the result of a function for each argument it has seen, so that a repeated call returns the saved result instead of computing it again.
References
- Lambda Expressions: The fun Keyword (Microsoft Learn)
- Functions (Microsoft Learn)
- Using functions in F# (Microsoft Learn)
- Values (Microsoft Learn)
- Reference Cells (Microsoft Learn)
- What's new in F# 6 (Microsoft Learn)
- F# RFC FS-1111: reference cell operation advisory messages (F# Software Foundation (fslang-design))
- Compiler options (opt-in warnings) (Microsoft Learn)
- InlineIfLambdaAttribute (FSharp.Core documentation)
- F# coding conventions: prefer let mutable to ref (Microsoft Learn)
- F# component design guidelines (Microsoft Learn)
- Dictionary<TKey,TValue>.TryGetValue method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress