Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

F#  Module 4 – Modelling data with types

Options, value options and nullness

Model missing values in F# with option and value option, turn the nulls that .NET methods return into options, and catch them with F# 9 nullness checks.

  • Beginner
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Replace a nested match on options with Option.bind, Option.map, Option.defaultValue and Option.orElse
  • Choose between option and value option from what each allocates and copies
  • Convert .NET nulls and Nullable values into options at the boundary, and back again
  • Switch on F# 9 nullness checking and fix FS3261 warnings with a null pattern

Before you start

What you’ll learn · Before you start

What you will learn

  • Replace a nested match on options with Option.bind, Option.map, Option.defaultValue and Option.orElse
  • Choose between option and value option from what each allocates and copies
  • Convert .NET nulls and Nullable values into options at the boundary, and back again
  • Switch on F# 9 nullness checking and fix FS3261 warnings with a null pattern

Before you start

On this page

Of the three customer numbers this lesson looks up, one has an email address, one has none and one does not exist. In C# both gaps usually arrive as null, and one forgotten check is a NullReferenceException in production. F# gives a missing value a type of its own, the option: An F# value that is either Some value or None, used instead of null for something that may be missing. Functions such as Option.defaultValue and Option.map handle both cases. More in “From C# to F#: a side-by-side syntax map”: Some value or None, and the compiler makes every caller say what None means.

You will replace nested matches with the Option module, choose between option and value option from measured allocations (6,024,032 bytes against 32 for the same million lookups), turn the nulls that .NET methods return into options at the edge of your code, and switch on F# 9 nullness checking, which flags a null that plain F# lets through without a word. Every run was recorded on .NET 10.

An option puts the missing case into the type

A customer record can say in its type which data may be missing: Email: string option holds Some "Asha.Rao@Example.com" or None, so no reader mistakes a missing address for an empty one. A lookup can fail too, and Map.tryFind says so in its result, an option. lookup.fsx asks for the email address of customers 1, 2 and 3:

lookup.fsx: a lookup that can fail, and a field that can be empty FSX · lookup.fsx
// Three customer numbers: one with an email address, one without, and one that does not exist.
type Customer = { Id: int; Name: string; Email: string option }

let customers =
    Map [ 1, { Id = 1; Name = "Asha"; Email = Some "Asha.Rao@Example.com" }
          2, { Id = 2; Name = "Ravi"; Email = None } ]

// Map.tryFind returns an option: Some customer, or None when the number is not in the map.
let tryCustomer id = Map.tryFind id customers

let contactNested id =
    match tryCustomer id with
    | None -> "no such customer"
    | Some c ->
        match c.Email with
        | Some email -> email.ToLowerInvariant()
        | None -> $"no email for {c.Name}"

for id in [ 1; 2; 3 ] do
    printfn "%d: %s" id (contactNested id)
lookup.fsx, then a match that forgets None Python · lookup_session.py
from session import fsi, run

# lookup.fsx, then the types F# Interactive gives an option, and a match that forgets None.
run("""
dotnet fsi lookup.fsx
""")
fsi([
    'Map.tryFind;;',
    'let names = Map [ 1, "Asha"; 2, "Ravi" ];;',
    'let nameOf id =\n    match Map.tryFind id names with\n    | Some name -> name;;',
    'nameOf 2;;',
    'nameOf 3;;',
    '#quit;;',
], args=["--nologo"])

Output

$ dotnet fsi lookup.fsx
1: asha.rao@example.com
2: no email for Ravi
3: no such customer
$ dotnet fsi --nologo
> Map.tryFind;;
val it: ('a -> Map<'a,'b> -> 'b option) when 'a: comparison

> let names = Map [ 1, "Asha"; 2, "Ravi" ];;
val names: Map<int,string> = map [(1, "Asha"); (2, "Ravi")]

> let nameOf id =
-     match Map.tryFind id names with
-     | Some name -> name;;

      match Map.tryFind id names with
  ----------^^^^^^^^^^^^^^^^^^^^

stdin(4,11): warning FS0025: Incomplete pattern matches on this expression. For example, the value 'None' may indicate a case not covered by the pattern(s).

val nameOf: id: int -> string

> nameOf 2;;
val it: string = "Ravi"

> nameOf 3;;
Microsoft.FSharp.Core.MatchFailureException: The match cases were incomplete
   at FSI_0004.nameOf(Int32 id) in stdin:line 5
   at <StartupCode$FSI_0006>.$FSI_0006.main@() in stdin:line 7
   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Stopped due to error

> #quit;;

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookup_session.py

The three answers come from three different rules: Asha has an address, Ravi has a record without one, and number 3 is not a customer at all. Step through them:

Interactive · stage by stage How contactNested answers for three customer numbers Step through the three lookups. Each option is matched, and each None gets its own rule and its own answer.
How contactNested answers for three customer numbers, step 6 of 6. Showing tryCustomer id, match: Some c or None? (None), match c.Email (not reached), the answer (3: no such customer). In focus: match: Some c or None?, the answer.tryCustomer idMap.tryFind: an optionmatch: Some c or None?Nonematch c.Emailnot reachedthe answer3: no such customer

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Customer 1. tryCustomer 1 finds Asha, so Map.tryFind returns Some with her record and the first match takes the Some c rule.
  2. Asha has an email. Her Email field is Some "Asha.Rao@Example.com", so the inner match takes Some email and lowers its case.
  3. The first answer. contactNested 1 is asha.rao@example.com, the first line lookup.fsx printed.
  4. Customer 2. tryCustomer 2 finds Ravi, so the first match takes Some c again. His record exists, but its Email field is None.
  5. A missing email. The inner None rule answers no email for Ravi. The record type made the gap visible: Email is a string option, not a string.
  6. Customer 3. Map.tryFind 3 finds nothing and returns None, so the outer None rule answers no such customer and the inner match never runs.

F# Interactive printed the library’s own promise: Map.tryFind returns 'b option. An option is a union of two cases, so a match over it is checked like any union. nameOf handled only Some, and the compiler warned FS0025 and named the value it had missed, None. That warning is the point of the type: nameOf 2 worked, and nameOf 3 stopped with MatchFailureException, the kind of crash that a forgotten null check gives in C#, except that here the compiler pointed at it first.

Silencing FS0025 with a wildcard

Wrong: | _ -> "" after the Some rule, which hides a missing customer behind an empty string that later code takes for a real name. Right: a None rule that says what a missing customer means, as contactNested does with “no such customer”.

Checkpoint

Check: Options and the check for None

  1. Question 1 of 2 nameOf matched Map.tryFind id names with a Some rule only. What did the compiler say?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Warning FS0025, naming the value 'None' as a case not covered

    The compiler checks a match over an option like a match over any union. FS0025 named None, and nameOf 3 then stopped with MatchFailureException, exactly where the warning pointed.

  2. Question 2 of 2 Why did contactNested 2 answer "no email for Ravi" and not "no such customer"?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Ravi is in the map, so tryCustomer returned Some, and the None came from his Email field

    Two different things can be missing here, the customer and the address, and the nested match gives each its own answer.

The Option module replaces the nested match

The nested match works, but it grows a level with every optional step. Each function of the Option module handles one step and passes a None straight on, so the same question becomes a pipeline. pipeline.fsx asks it that way, then compares two ways of supplying a default:

pipeline.fsx: the lookup as a pipeline, and two kinds of default FSX · pipeline.fsx
// lookup.fsx's question as a pipeline of Option functions, with no nested match.
type Customer = { Id: int; Name: string; Email: string option }

let customers =
    Map [ 1, { Id = 1; Name = "Asha"; Email = Some "Asha.Rao@Example.com" }
          2, { Id = 2; Name = "Ravi"; Email = None } ]

let tryCustomer id = Map.tryFind id customers

let contact id =
    tryCustomer id
    |> Option.bind (fun c -> c.Email)
    |> Option.map (fun email -> email.ToLowerInvariant())
    |> Option.defaultValue "no email on file"

for id in [ 1; 2; 3 ] do
    printfn "%d: %s" id (contact id)

// A default that takes work to build: defaultValue builds it first, defaultWith only when the option is None.
let supportAddress () =
    printfn "  (building the support address)"
    "support@example.com"

printfn "defaultValue on Some:"
let a = Some "asha.rao@example.com" |> Option.defaultValue (supportAddress ())
printfn "defaultWith on Some:"
let b = Some "asha.rao@example.com" |> Option.defaultWith supportAddress
printfn "defaultWith on None:"
let c = None |> Option.defaultWith supportAddress
printfn "%s | %s | %s" a b c

// The first source that has a value wins.
let fromCommandLine: string option = None
let fromEnvironment = Some "INR"
let fromFile = Some "USD"
printfn "currency: %A" (fromCommandLine |> Option.orElse fromEnvironment |> Option.orElse fromFile)
pipeline.fsx, then the signatures of four Option functions Python · pipeline_session.py
from session import fsi, run

# pipeline.fsx, then the signatures of the four Option functions it uses.
run("""
dotnet fsi pipeline.fsx
""")
fsi([
    'Option.map;;',
    'Option.bind;;',
    'Option.defaultValue;;',
    'Option.orElse;;',
    '#quit;;',
], args=["--nologo"])

Output

$ dotnet fsi pipeline.fsx
1: asha.rao@example.com
2: no email on file
3: no email on file
defaultValue on Some:
  (building the support address)
defaultWith on Some:
defaultWith on None:
  (building the support address)
asha.rao@example.com | asha.rao@example.com | support@example.com
currency: Some "INR"
$ dotnet fsi --nologo
> Option.map;;
val it: (('a -> 'b) -> 'a option -> 'b option)

> Option.bind;;
val it: (('a -> 'b option) -> 'a option -> 'b option)

> Option.defaultValue;;
val it: ('a -> 'a option -> 'a)

> Option.orElse;;
val it: ('a option -> 'a option -> 'a option)

> #quit;;

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline_session.py

The signatures F# Interactive printed say what each function does:

  • Option.map takes a function 'a -> 'b and applies it inside a Some; a None stays None.
  • Option.bind takes a function that itself returns an option, 'a -> 'b option, and does not wrap its result again. fun c -> c.Email is such a function, so bind gives a string option where map would give a string option option.
  • Option.defaultValue takes the value out of a Some and gives its default for None, so the result is a plain value again.
  • Option.orElse keeps the first option that is Some: the currency came from the environment because the command line had none.

Step through customer 2, then customer 1:

Interactive · stage by stage Two customers through the Option pipeline Step through contact 2, then contact 1. Each stage passes a None straight on and only works on a Some.
Two customers through the Option pipeline, step 7 of 7. Showing tryCustomer id (Some Asha), Option.bind (Some "Asha.Rao@…"), Option.map (Some "asha.rao@…"), Option.defaultValue (asha.rao@example.com). In focus: Option.defaultValue.tryCustomer idSome AshaOption.bindSome "Asha.Rao@…"Option.mapSome "asha.rao@…"Option.defaultValueasha.rao@example.com

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Ravi is found. tryCustomer 2 returns Some with Ravi's record, the same first step as the nested match.
  2. bind takes his Email. Option.bind calls the function on Ravi's record. The function returns his Email field, which is None, and bind returns that None without wrapping it again.
  3. map skips a None. Option.map has nothing to lower, so it passes the None on and never calls ToLowerInvariant.
  4. The default. Option.defaultValue turns the None into "no email on file", the second line pipeline.fsx printed.
  5. Asha is found. Now contact 1. tryCustomer 1 returns Some with Asha's record, and bind returns her Email field, Some "Asha.Rao@Example.com".
  6. map changes the value. Option.map calls ToLowerInvariant on the text inside the Some and wraps the result in a new Some.
  7. The value comes out. Option.defaultValue returns the value inside the Some, so contact 1 is asha.rao@example.com, the same answer as the nested match.

The pipeline gave customers 2 and 3 the same answer, “no email on file”, where the nested match said why. Merging the two reasons is fine for a contact line on an invoice. When the caller must tell them apart, keep the match, or return a Result, which carries the reason with it; the track’s module on errors and validation teaches it.

A default that does work, given to defaultValue

Wrong: Option.defaultValue (supportAddress ()), which builds the default before it looks at the option: the run printed “(building the support address)” for a Some that never needed it. Right: Option.defaultWith supportAddress, which calls the function only for None, as the third case shows.

Option.orElse has a lazy twin for the same reason, Option.orElseWith, which builds its fallback option only when the first one is None.

Key idea

Use map for a function that returns a plain value and bind for one that returns an option, and leave the option once, at the end, with defaultValue, or with defaultWith when the default takes work to build.

Checkpoint

Check: Option.map, bind and the defaults

  1. Question 1 of 2 fun c -> c.Email returns a string option. Which function chains it after tryCustomer id so that the result is a string option?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Option.bind

    Use map for a function that returns a plain value and bind for one that returns an option, as the signatures 'a -> 'b and 'a -> 'b option in the session show.

  2. Question 2 of 2 How many times did pipeline.fsx print "(building the support address)"?

    Type a number.

    A hint

    Count the default that was built although the option was Some, and the one that was needed.

    Show the answer: Question 2 of 2

    Answer: 2 times

    Once for defaultValue on a Some, which built the default it then threw away, and once for defaultWith on a None, which needed it. defaultWith on a Some printed nothing.

A value option stores its answer inline, with no object

Every Some of an option is an object on the heap, which the garbage collector later has to find and reclaim. A value option: The struct form of an option, written voption, with the cases ValueSome and ValueNone. It is stored where the variable is, so creating one allocates nothing on the heap, but the whole value is copied wherever it goes., written voption, has the cases ValueSome and ValueNone and is a struct, stored where the variable is. allocation.fsx gives a million orders 5 % cashback from 750 rupees up, once with each type, and counts the bytes the thread allocated:

allocation.fsx: the same cashback with option and with value option FSX · allocation.fsx
open System
open System.Globalization

// 5 % cashback on orders of 750 rupees or more: a million orders of 1 to 1,000 rupees, each amount a thousand times.
let tryCashback amount = if amount >= 750 then Some(amount / 20) else None
let tryCashbackV amount = if amount >= 750 then ValueSome(amount / 20) else ValueNone

let withOption () =
    let mutable total = 0L
    for i in 0..999_999 do
        match tryCashback (i % 1000 + 1) with
        | Some c -> total <- total + int64 c
        | None -> ()
    total

let withValueOption () =
    let mutable total = 0L
    for i in 0..999_999 do
        match tryCashbackV (i % 1000 + 1) with
        | ValueSome c -> total <- total + int64 c
        | ValueNone -> ()
    total

// Bytes this thread allocated while f ran, and f's result.
let allocated f =
    let before = GC.GetAllocatedBytesForCurrentThread()
    let result = f ()
    GC.GetAllocatedBytesForCurrentThread() - before, result

// One run of each first, so the measured runs include no first-call work.
withOption () |> ignore
withValueOption () |> ignore

let optionBytes, optionTotal = allocated withOption
let valueBytes, valueTotal = allocated withValueOption
let n (x: int64) = x.ToString("N0", CultureInfo.InvariantCulture)
printfn "option:       %10s bytes allocated, cashback %s rupees" (n optionBytes) (n optionTotal)
printfn "value option: %10s bytes allocated, cashback %s rupees" (n valueBytes) (n valueTotal)

// What a variable or an array element of each type holds.
printfn "size: int option %d, int voption %d, float voption %d, decimal voption %d bytes"
    sizeof<int option> sizeof<int voption> sizeof<float voption> sizeof<decimal voption>

// The ValueOption module mirrors the Option module, and the two convert into each other.
printfn "%A %A" (tryCashbackV 800 |> ValueOption.map (fun c -> c * 2)) (tryCashbackV 100 |> ValueOption.defaultValue 0)
printfn "%A %A" (Option.toValueOption (tryCashback 900)) (Option.ofValueOption (tryCashbackV 900))
allocation.fsx on the pinned SDK Python · allocation_session.py
from session import run

# allocation.fsx counts the bytes a million lookups allocate with an option and with a value option.
run("""
dotnet fsi allocation.fsx
""")

Output

$ dotnet fsi allocation.fsx
option:        6,024,032 bytes allocated, cashback 10,860,000 rupees
value option:         32 bytes allocated, cashback 10,860,000 rupees
size: int option 8, int voption 8, float voption 16, decimal voption 24 bytes
ValueSome 80 0
ValueSome 45 Some 45

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 allocation_session.py

251 amounts in every thousand qualify, so the loop made 251,000 Some values: 6,024,032 bytes, which is 24 bytes each plus the 32 bytes that both runs allocated. The value option run allocated only those 32 bytes for the same 10,860,000 rupees of cashback. The cost moved rather than vanished: a value option is copied whole wherever it goes, 8 bytes for an int voption, 16 for a float voption and 24 for a decimal voption, where an option is always one 8-byte reference. The F# coding conventions suggest a struct for data of 16 bytes or less that is made at a high rate, and say to measure before deciding. Answer the questions for a value you are modelling:

Interactive · answer and compare Option or value option? Answer three questions about the missing value you are modelling. The table marks the form that fits less well.
Is the value made millions of times on a path that must stay fast?
Is the value inside larger than 16 bytes, such as a struct record of several fields?
Does it come from, or go to, library functions that use option?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

The two optional types against what this lesson measured and what the F# libraries return
Type Allocates per Some Bytes copied Library functions
option Written: Some x or None Poor an object each: 6,024,032 bytes for 251,000 Somes Good a reference, 8 bytes, whatever the value Good what Map.tryFind and List.tryFind return
value option Written: ValueSome x or ValueNone Good none: the cashback run allocated 32 bytes in all Mixed the whole value: 8 bytes for an int, 24 for a decimal Mixed the ValueOption module; convert with Option.toValueOption
How the answers decide
  • “Is the value made millions of times on a path that must stay fast?” Yes, a hot path: adds a caveat to option. Each Some is an object of 24 bytes: 251,000 of them allocated 6,024,032 bytes, against 32 for the value option.
  • “Is the value made millions of times on a path that must stay fast?” No, now and then: adds a caveat to value option. Off the hot path the allocation is too small to matter, and option reads the same as the rest of the library.
  • “Is the value inside larger than 16 bytes, such as a struct record of several fields?” Yes, larger than 16 bytes: adds a caveat to value option. A value option is copied whole on every pass and return. The F# conventions suggest a struct for data of 16 bytes or less.
  • “Does it come from, or go to, library functions that use option?” Yes, Map.tryFind and friends: adds a caveat to value option. Map.tryFind, List.tryFind and Option.ofObj return option, so a value option needs a conversion at each of them.

The ValueOption module has functions to match the Option module’s, ValueOption.map and ValueOption.defaultValue among them, and Option.toValueOption and Option.ofValueOption convert where the two meet. F# 10 also lets an optional parameter of a method be a value option: mark the parameter [<Struct>].

Checkpoint

Check: Value options and what they cost

  1. Question 1 of 2 The option run allocated 6,024,032 bytes for 251,000 Some values, and both runs allocated 32 bytes besides. How many bytes did each Some take?

    Type a number.

    A hint

    Take away what both runs allocated, then share the rest among the Some values.

    Show the answer: Question 1 of 2

    Answer: 24 bytes

    6,024,032 − 32 = 6,024,000 bytes, and 6,024,000 ÷ 251,000 = 24 bytes for each Some object. The value option run allocated the 32 bytes only.

  2. Question 2 of 2 How many bytes did allocation.fsx print for a decimal voption?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 24

    A value option saves the allocation and pays with copies: the larger the value inside, the more each pass and return copies.

Nulls from .NET become options at the boundary

F# types never hold null on their own, but .NET methods return it: Environment.GetEnvironmentVariable returns null for a variable that is not set, as its documentation says. Convert once, where the value enters your code, and use options from there on. boundary.fsx does it for three common shapes:

boundary.fsx: a null, an out value and a Nullable turned into options FSX · boundary.fsx
open System
open System.Collections.Generic

// A .NET method that returns null: this variable is not set in the session.
let raw = Environment.GetEnvironmentVariable "SHOP_REGION"
printfn "GetEnvironmentVariable returned null: %b" (isNull raw)
printfn "Some raw:          %A" (Some raw)
printfn "Option.ofObj raw:  %A" (Option.ofObj raw)

// TryGetValue reports a missing key with false and an out value; F# returns both as a tuple.
let settings = Dictionary<string, string>()
settings["currency"] <- "INR"
let tryRead key =
    match settings.TryGetValue key with
    | true, value -> Some value
    | false, _ -> None
printfn "tryRead:           %A %A" (tryRead "currency") (tryRead "locale")

// Nullable<int>, a C# int?, converts with Option.ofNullable and back with Option.toNullable.
printfn "Option.ofNullable: %A %A" (Option.ofNullable (Nullable<int>())) (Option.ofNullable (Nullable 500))
printfn "Option.toNullable: %b" (Option.toNullable (None: int option)).HasValue

// The way back for a reference type: None becomes null.
printfn "Option.toObj None is null: %b" (isNull (Option.toObj (None: string option)))

// None is stored as null, so a caller outside F# sees null.
printfn "None is null at run time: %b" (obj.ReferenceEquals((None: int option), null))
try
    printfn "%d" (None: int option).Value
with e -> printfn ".Value on None:      %s" (e.GetType().Name)
try
    printfn "%d" (Option.get (None: int option))
with e -> printfn "Option.get on None:  %s" (e.GetType().Name)
boundary.fsx on the pinned SDK Python · boundary_session.py
from session import run

# boundary.fsx turns the nulls of three .NET APIs into options, and back.
run("""
dotnet fsi boundary.fsx
""")

Output

$ dotnet fsi boundary.fsx
GetEnvironmentVariable returned null: true
Some raw:          Some null
Option.ofObj raw:  None
tryRead:           Some "INR" None
Option.ofNullable: None Some 500
Option.toNullable: false
Option.toObj None is null: true
None is null at run time: true
.Value on None:      NullReferenceException
Option.get on None:  ArgumentException

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 boundary_session.py

Step through the conversions, left to right and back:

Interactive · step by step Where a .NET null becomes an F# option Step through the conversions boundary.fsx makes. The left column is what .NET hands over, the right what F# code gets.
Where a .NET null becomes an F# option, step 7 of 7. Showing GetEnvironmentVariable (returned null), string option (Option.ofObj: None), TryGetValue, string option (Some "INR", None), Nullable<int>, int option (None, Some 500), null for .NET code, None. In focus: None.Option.ofObjmatch (true, v)Option.ofNullableOption.toObjGetEnvironmentVariablereturned nullstring optionOption.ofObj: NoneTryGetValuereturns true or falsestring optionSome "INR", NoneNullable<int>a C# int?int optionNone, Some 500null for .NET codewhat a C# caller seesNonean F# valuestored as null at run time

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A method returns null. SHOP_REGION is not set, so Environment.GetEnvironmentVariable returned null, which its documentation promises for a variable that does not exist.
  2. Some wraps the null. Some raw printed Some null: Some wraps whatever it is given, null included, so the null is still there inside the option.
  3. ofObj turns null into None. Option.ofObj raw printed None. It returns None for null and Some for anything else, so the code after it cannot meet a null.
  4. A key that is missing. TryGetValue reports a missing key with false. F# returns the result and the out value as a tuple, so a match on (true, value) gives Some "INR" and None for locale.
  5. An empty Nullable. A Nullable<int> is a struct, not a null reference. Option.ofNullable turned an empty one into None and Nullable 500 into Some 500.
  6. The way back. Option.toObj turns None back into null for .NET code that expects one, and Option.toNullable gives an empty Nullable.
  7. None is null inside. F# stores None as null: obj.ReferenceEquals(None, null) printed true, and .Value on None threw NullReferenceException. A C# caller of an F# option sees exactly that null.

Some raw printed Some null: wrapping is not converting, and the null inside would still throw later. Option.ofObj converts, and it is what the F# coding conventions use for a .NET result that may be null. The false of TryGetValue and an empty Nullable: System.Nullable<'T>, a .NET struct that wraps a value type such as int with a HasValue flag so that it can be empty; C# writes it int?. Option.ofNullable and Option.toNullable convert it. became None the same way.

The last lines show the trap in the other direction. F# stores None as null, so .Value on it threw NullReferenceException, and C# code that calls an F# function returning string option receives null for every None. A public API meant for C# returns a type that C# already understands, such as a string that may be null or a TryGet method with an out value, and keeps its options inside.

Option.get and .Value are the null check you meant to avoid

Both fail on None, Option.get with ArgumentException and .Value with NullReferenceException, so code that calls them crashes just as unchecked null does. Match instead, or use defaultValue.

Checkpoint

Check: Turning .NET nulls into options

  1. Question 1 of 2 raw came from GetEnvironmentVariable for a variable that is not set. What did Some raw print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Some null

    Wrapping is not converting. Option.ofObj is the conversion: None for null and Some for anything else.

  2. Question 2 of 2 An F# function returns string option. What does a C# caller hold when the result is None?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: null

    A public API meant for C# returns a type C# expects, such as a string that may be null or a TryGet method, and keeps its options inside.

F# 9 nullness checking shows where null can still arrive

Without help, the compiler does not track null: a string from .NET has the plain type string, which may hold null, and nothing warns when it is used unchecked. F# 9 added nullness checking: F# 9's opt-in checks for null, switched on with <Nullable>enable</Nullable> in a project or dotnet fsi --checknulls+. A type that may hold null is written string | null, and an unchecked use warns FS3261., which is off by default: <Nullable>enable</Nullable> in a project file switches it on by passing the --checknulls+ option to the compiler, and a script gets the same with dotnet fsi --checknulls+. The session types the same entries without the option and with it, then builds a project that switches it on in its project file:

nullcheck/Program.fs FS · nullcheck/Program.fs
open System

// Not set when the program runs, so the method returns null.
let region = Environment.GetEnvironmentVariable "SHOP_REGION"

let shout (text: string) = text.ToUpperInvariant()

let shoutSafe (text: string | null) =
    match text with
    | null -> "(not set)"
    | t -> t.ToUpperInvariant()

printfn "%s" (shoutSafe region)
printfn "%s" (shout region)
nullcheck/NullCheck.fsproj FSPROJ · nullcheck/NullCheck.fsproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <!-- F# 9 nullness checking: the compiler's checknulls option. -->
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="Program.fs" />
  </ItemGroup>

</Project>
Without checking, with checking, and in a build Python · nullness_session.py
from session import fsi, run

# The same entries without and with nullness checking, then a project that switches it on in its project file.
fsi([
    'let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;',
    'let shout (text: string) = text.ToUpperInvariant();;',
    'shout region;;',
    'let size (text: string | null) = text.Length;;',
    '#quit;;',
], args=["--nologo"])
fsi([
    'let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;',
    'let shout (text: string) = text.ToUpperInvariant();;',
    'shout region;;',
    'let shoutSafe (text: string | null) =\n    match text with\n    | null -> "(not set)"\n    | t -> t.ToUpperInvariant();;',
    'shoutSafe region;;',
    'let code: string = region |> Option.ofObj |> Option.defaultValue "IN";;',
    '#quit;;',
], args=["--nologo", "--checknulls+"])
run("""
dotnet build nullcheck -v q
""")

Output

$ dotnet fsi --nologo
> let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;
val region: string = <null>

> let shout (text: string) = text.ToUpperInvariant();;
val shout: text: string -> string

> shout region;;
System.NullReferenceException: Object reference not set to an instance of an object.
   at <StartupCode$FSI_0004>.$FSI_0004.main@() in stdin:line 3
   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Stopped due to error

> let size (text: string | null) = text.Length;;
val size: text: (string | null) -> int

> #quit;;
$ dotnet fsi --nologo --checknulls+
> let region = System.Environment.GetEnvironmentVariable "SHOP_REGION";;
val region: string | null = <null>

> let shout (text: string) = text.ToUpperInvariant();;
val shout: text: string -> string

> shout region;;

  shout region;;
  ------^^^^^^

stdin(3,7): warning FS3261: Nullness warning: A non-nullable 'string' was expected but this expression is nullable. Consider either changing the target to also be nullable, or use pattern matching to safely handle the null case of this expression.

System.NullReferenceException: Object reference not set to an instance of an object.
   at <StartupCode$FSI_0004>.$FSI_0004.main@() in stdin:line 3
   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Stopped due to error

> let shoutSafe (text: string | null) =
-     match text with
-     | null -> "(not set)"
-     | t -> t.ToUpperInvariant();;
val shoutSafe: text: (string | null) -> string

> shoutSafe region;;
val it: string = "(not set)"

> let code: string = region |> Option.ofObj |> Option.defaultValue "IN";;
val code: string = "IN"

> #quit;;
$ dotnet build nullcheck -v q
nullcheck/Program.fs(14,21): warning FS3261: Nullness warning: A non-nullable 'string' was expected but this expression is nullable. Consider either changing the target to also be nullable, or use pattern matching to safely handle the null case of this expression. [nullcheck/NullCheck.fsproj]

Build succeeded.

nullcheck/Program.fs(14,21): warning FS3261: Nullness warning: A non-nullable 'string' was expected but this expression is nullable. Consider either changing the target to also be nullable, or use pattern matching to safely handle the null case of this expression. [nullcheck/NullCheck.fsproj]
    1 Warning(s)
    0 Error(s)

Time Elapsed …

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nullness_session.py

Without checking, F# Interactive gave region the type string and the value <null>, shout region failed at run time with no warning, and even size, which reads Length from a string | null, compiled without one. With checking, the same method’s result had the type string | null, because .NET marks it as possibly null, and shout region drew warning FS3261: a non-nullable string was expected. The warning did not stop the call, which still threw, but it pointed at the line before anything ran, and the build printed the same FS3261 for line 14 of Program.fs. The fix is a match that takes the null case first:

Interactive · stage by stage How a null pattern narrows string | null Step through shoutSafe region with nullness checking on. The null rule takes the null away, so the next rule has a plain string.
How a null pattern narrows string | null, step 4 of 4. Showing region (string | null = <null>), | null -> "(not set)", rule 2: | t -> … (t: string, no warning), the result (NullReferenceException). In focus: region.regionstring | null = <null>| null -> "(not set)"takes the null caserule 2: | t -> …t: string, no warningthe resultNullReferenceExceptionshout region: FS3261, then a crash

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. A nullable value. With checking on, F# Interactive gave region the type string | null, because .NET marks GetEnvironmentVariable's result as possibly null.
  2. The null rule. region is null, so the first rule fits and shoutSafe returns "(not set)" without calling a method on the null.
  3. The type narrows. The second rule runs only for values that are not null, so the compiler gives t the type string, and t.ToUpperInvariant() gets no warning.
  4. Without the match. shout region passed the string | null where a string was expected. The compiler warned FS3261, the call still ran, and it threw NullReferenceException.

After | null -> the compiler knows that the rest of the match sees no null, so t has the type string and needs no check. Option.ofObj takes a string | null as well, which makes it the usual way out of the nullable world. The F# reference adds a rule of style: | null belongs at the edges where .NET code meets yours, and options stay the way to model a value that may be missing.

Nullness checking, F# 9 and later

The string | null syntax and the FS3261 warnings came with F# 9 and .NET 9. A project or script that does not switch the checks on still compiles the syntax and checks nothing, as size showed in the first session.

Checkpoint

Check: F# 9 nullness checking

  1. Question 1 of 2 With --checknulls+, what type did F# Interactive give region, the result of GetEnvironmentVariable?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: string | null

    The checks change the type the compiler gives .NET results, so the possible null is visible where the value is used.

  2. Question 2 of 2 With checking on, shout region drew warning FS3261. What happened when the entry ran?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It still ran, and threw NullReferenceException

    Nullness checking points at the line before the program runs; fixing it is still your job, with a null pattern or Option.ofObj.

Practise: settings from three sources

practice.fsx resolves a setting the way many programs do: the command line first, then an environment variable, then a settings file, then a default. Its file lookup is written in the .NET style and returns null for a missing key, and the script runs with nullness checking on:

practice.fsx: one setting from four places FSX · practice.fsx
open System
open System.Collections.Generic

// A settings file read into a dictionary, and a lookup in the .NET style: null for a key that is missing.
let fileSettings = Dictionary<string, string>()
fileSettings["currency"] <- "INR"
fileSettings["locale"] <- ""

let readSetting (key: string) : string | null =
    match fileSettings.TryGetValue key with
    | true, value -> value
    | false, _ -> null

// At the boundary the null becomes None, and nothing after it sees a null.
let tryReadSetting key = readSetting key |> Option.ofObj

// An empty value is present, but this program treats it as not set.
let nonEmpty (value: string) =
    if String.IsNullOrWhiteSpace value then None else Some value

// The command line first, then the environment (SHOP_CURRENCY …), then the file, then the default.
let resolve (commandLine: string option) (key: string) fallback =
    commandLine
    |> Option.orElse (Environment.GetEnvironmentVariable $"SHOP_{key.ToUpperInvariant()}" |> Option.ofObj)
    |> Option.orElse (tryReadSetting key |> Option.bind nonEmpty)
    |> Option.defaultValue fallback

printfn "in the file: %A %A %A" (tryReadSetting "currency") (tryReadSetting "locale") (tryReadSetting "timezone")
printfn "currency: %s" (resolve None "currency" "USD")
printfn "currency: %s" (resolve (Some "EUR") "currency" "USD")
printfn "locale:   %s" (resolve None "locale" "en-IN")
printfn "timezone: %s" (resolve None "timezone" "UTC")
practice.fsx, then with SHOP_CURRENCY set Python · practice_session.py
from session import run

# practice.fsx with nullness checking on, once as it is and once with the currency set in the environment.
run("""
dotnet fsi --checknulls+ practice.fsx
SHOP_CURRENCY=GBP dotnet fsi --checknulls+ practice.fsx
""")

Output

$ dotnet fsi --checknulls+ practice.fsx
in the file: Some "INR" Some "" None
currency: INR
currency: EUR
locale:   en-IN
timezone: UTC
$ SHOP_CURRENCY=GBP dotnet fsi --checknulls+ practice.fsx
in the file: Some "INR" Some "" None
currency: GBP
currency: EUR
locale:   en-IN
timezone: UTC

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

The checks found nothing to warn about, because the null never leaves tryReadSetting. The file’s locale is present but empty, Some "", which nonEmpty treats as not set, so locale fell back to en-IN, and with SHOP_CURRENCY=GBP in the environment the second run took GBP ahead of the file’s INR. An explicit Some "EUR" from the command line won in both runs.

Tell an empty value from a missing one

Replace Option.bind nonEmpty with Option.bind Some and run dotnet fsi --checknulls+ practice.fsx again: locale becomes the empty string from the file, while timezone, which the file does not have, still falls back to UTC.

Test the five ideas of this lesson below. Each answer is explained as soon as you pick it, and the questions turn first to whichever idea you have answered least well so far:

Practice that adapts to you

Practise this lesson

13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 What does Map.tryFind return for a key that is not in the map?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: None

    Functions whose names start with try return an option by convention, so a missing result is a case the caller has to handle.

  2. Question 2 of 13 A Customer record must allow a missing phone number. Which field type fits best in F# domain code?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: Phone: string option

    An option makes the missing case part of the record's type, which is what lookup.fsx did for Email.

  3. Question 3 of 13 fromCommandLine is None, fromEnvironment is Some "INR" and fromFile is Some "USD". What does fromCommandLine |> Option.orElse fromEnvironment |> Option.orElse fromFile give?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Some "INR"

    a |> Option.orElse b reads as "a, or else b", so a chain of orElse calls lists the sources in order of preference. pipeline.fsx printed currency: Some "INR".

  4. Question 4 of 13 What does Option.map (fun (e: string) -> e.ToLowerInvariant()) None return?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: None

    This is how the pipeline skipped ToLowerInvariant for Ravi, whose Email was None.

  5. Question 5 of 13 An invoice must say whether the customer is unknown or has no email address on file. Is the bind, map and defaultValue pipeline enough?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: No, because both cases become None; keep the match or return a Result with the reason

    An option says that something is missing, not why. When the why matters, use a match or a Result.

  6. Question 6 of 13 How many bytes did the value option run of allocation.fsx allocate in all?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: 32

    The value option keeps its result where the variable is, so the garbage collector has nothing to collect.

  7. Question 7 of 13 A parser returns an optional [<Struct>] record of six decimal fields millions of times a minute. Should the result be a value option?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Measure first; a value option would copy about 96 bytes or more on every pass and return

    The run showed the saving for an int. For large values the copying grows, so the choice depends on a measurement of the real code.

  8. Question 8 of 13 Which function turns a string that may be null into a string option?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Option.ofObj

    Convert at the boundary with ofObj for references, ofNullable for Nullable values and a match for TryGetValue.

  9. Question 9 of 13 How does F# hand you the out value of Dictionary.TryGetValue when you call it with only the key?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: As a tuple: (true, value) or (false, default)

    match settings.TryGetValue key with | true, value -> Some value | false, _ -> None is the usual shape.

  10. Question 10 of 13 Your F# library is called mostly from C#, and a lookup may find nothing. What should its public method return?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: A type C# understands, such as a TryGet method with an out value or a string that may be null

    Options are the right tool inside F# code; at a public boundary for C#, use the conventions C# already has.

  11. Question 11 of 13 Which project-file setting switches on F# 9 nullness checking?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: <Nullable>enable</Nullable>

    Projects switch it on with the Nullable property and scripts with dotnet fsi --checknulls+.

  12. Question 12 of 13 With checking on, what type does t have in match text with | null -> 0 | t -> t.Length, where text is string | null?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: string

    A null pattern narrows the type for the rules after it, which is why shoutSafe needed no other check.

  13. Question 13 of 13 A teammate models a customer's middle name as string | null in a domain record now that nullness checking is on. What do you suggest?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Use string option, and keep | null for the edges where .NET values come in

    Nullness checking makes the nulls of .NET visible; options remain the F# way to model a value that may be absent.

Interview questions

Warm-up, for freshers and 2–5 years: how do option and value option differ in F#, and when does each fit?

Both say that a value may be missing, with the same matches and the same module functions. An option is a reference type: every Some is an object on the heap, and None is stored as null. A value option, voption, is a struct with the cases ValueSome and ValueNone, so creating one allocates nothing, but the whole value is copied wherever it goes. Use option by default, because the library returns it (Map.tryFind, List.tryFind, Option.ofObj) and the copies stay small. Use a value option on a hot path that creates many small results, such as a parser or a lookup called millions of times, after measuring: in this lesson a million cashback lookups allocated 6,024,032 bytes with option, an object for each of its 251,000 results, and 32 with value option.

Two harder questions follow, about an F# library whose None reached C# callers as null, and about switching on nullness checking in a large service:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    An F# library exposes tryFindCustomer: string -> Customer option. A C# service calls it, reads .Value and crashes with NullReferenceException whenever no customer is found, while every F# test passes. Explain why, and design the API so that both F# and C# callers handle a missing customer safely.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    Your team wants F# 9 nullness checking in a service of 180 F# files that calls many .NET libraries. A trial build with <Nullable>enable</Nullable> reports hundreds of FS3261 warnings. How do you roll the checks out without stopping feature work, and which warnings do you fix first?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An option puts a missing value into the type, so a match that forgets None is warning FS0025 instead of a crash found later.
  • Option.bind, Option.map and Option.defaultValue replace nested matches, and defaultWith and orElseWith build their fallback only when it is needed.
  • A value option needs no object for its result, so a million cashback lookups allocated 32 bytes with it against 6,024,032 with option, but it is copied whole and suits small values on hot paths.
  • Convert .NET nulls once, at the boundary, with Option.ofObj, Option.ofNullable or a match on TryGetValue, and remember that None reaches C# callers as null.
  • F# 9 nullness checking gives .NET results the type string | null and warns FS3261 where they are used unchecked, and a null pattern or Option.ofObj fixes the warning.

Terms in this lesson

Nullable
System.Nullable<'T>, a .NET struct that wraps a value type such as int with a HasValue flag so that it can be empty; C# writes it int?. Option.ofNullable and Option.toNullable convert it.
nullness checking
F# 9's opt-in checks for null, switched on with <Nullable>enable</Nullable> in a project or dotnet fsi --checknulls+. A type that may hold null is written string | null, and an unchecked use warns FS3261.
value option
The struct form of an option, written voption, with the cases ValueSome and ValueNone. It is stored where the variable is, so creating one allocates nothing on the heap, but the whole value is copied wherever it goes.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.