Option Type
Overview
Suppose string FindCity(int userId) looks up a city by user ID and returns null if the user or city is missing. The signature hides that possibility, so a caller that reads city.Length without checking gets NullReferenceException at runtime. A lookup chain through user, profile, and city repeats the null check at every step.
An option type puts possible absence in the return type: Some holds a value, None represents its absence. Haskell calls the same structure Maybe, with Just and Nothing. Retrieving the value goes through a function requiring both cases, so forgetting the absent case is caught at compile time.
Three operations connect lookups that may be absent along the way. Map applies a function only to a present value and passes None through; when the next function also returns Option, Bind connects it without nesting Option<Option<T>>. At the end, GetValueOrDefault chooses what should replace None.
Flow
Each Option continues as Some when a value exists. A None at any stage skips the rest, and the final GetValueOrDefault replaces it with the chosen fallback.
History
Tony Hoare introduced null references in ALGOL W in 1965. He explained that ease of implementation motivated the choice and called it his "billion-dollar mistake."1
Standard ML and Haskell put absence in their standard libraries as an algebraic data type, whose value is one of several cases. The Standard ML Basis Library defines datatype 'a option = NONE | SOME of 'a and provides getOpt for a default, map for applying a function, and mapPartial for connecting a function that returns an option. The Haskell 98 Report's standard Prelude defines data Maybe a = Nothing | Just a and maybe. Its Monad instance gives Nothing >>= k = Nothing, leaving k uncalled after an absent result.23
F# option and Rust Option<T> now provide the same structure as standard types. The F# documentation describes option as a common return type for searches that may not find an element.45
Why use it?
Option<string> makes possible absence visible before the string is retrieved. Match or GetValueOrDefault requires the absent case to be handled, making code that omits it fail to compile. Bind connects the lookups while moving repeated null checks into the chain and the choice of fallback to its end.
C# has no standard Option type. Nullable<T>, such as int?, provides HasValue and GetValueOrDefault for value types but cannot wrap reference types. For references, string? is a compile-time nullable annotation rather than a separate type: unchecked dereferences produce warnings, without changing runtime behavior. A short member-access chain can use ?. to return null when its receiver is null and ?? to supply an alternative. Option adds a value that can be stored and passed around to represent absence, with handling required by the type rather than a warning.67
None does not retain the reason for absence. Policy still determines whether to use a default, tell the user, or raise an error; distinguishing a missing user from a missing city requires a result type carrying that reason. A custom Option also needs conversions at boundaries with .NET APIs using null or TryGetValue.
Example
Look up an age by name in a Dictionary, convert TryGetValue into Option<int>, and pass both the present and absent handlers to Match. Option<T> is a sealed record with its constructor and value hidden, created through Some and None. Its operations accept Func<> delegates, with Map and Bind corresponding in shape to LINQ Select and SelectMany.
using System;
using System.Collections.Generic;
var ages = new Dictionary<string, int> { ["kim"] = 31 };
// Return a potentially absent lookup result as Option instead of null.
Option<int> FindAge(string name) =>
ages.TryGetValue(name, out var age) ? Option<int>.Some(age) : Option<int>.None;
foreach (var name in new[] { "kim", "lee" })
{
// Match requires handlers for both presence and absence.
string text = FindAge(name).Match(
some: age => $"{name}: {age}",
none: () => $"{name}: unknown");
Console.WriteLine(text);
}
// Keep HasValue and its value in one type; the value is not directly exposed.
sealed record Option<T>
{
private readonly T value;
public bool HasValue { get; }
private Option(T value, bool hasValue) { this.value = value; HasValue = hasValue; }
public static Option<T> Some(T value) => new(value, true);
public static Option<T> None { get; } = new(default!, false);
public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) =>
HasValue ? some(value) : none();
// Apply f only to a present value, passing None through.
public Option<TOut> Map<TOut>(Func<T, TOut> f) =>
HasValue ? Option<TOut>.Some(f(value)) : Option<TOut>.None;
// When f returns Option, connect it without nesting Option<Option<TOut>>.
public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> f) =>
HasValue ? f(value) : Option<TOut>.None;
public T GetValueOrDefault(T fallback) => HasValue ? value : fallback;
public override string ToString() => HasValue ? $"Some({value})" : "None";
}kim: 31
lee: unknownlee is absent from the dictionary, so the none handler runs without an exception.
A broader example
Extend the lookup through user, profile, and city. User 2 has no profile, user 3 has a profile but no city, and user 4 does not exist. Each line also prints CityOrNull, the same lookup written with null returns, ?., and ??.
using System;
using System.Collections.Generic;
var users = new Dictionary<int, User>
{
[1] = new User(1, ProfileId: 10),
[2] = new User(2, ProfileId: null),
[3] = new User(3, ProfileId: 30),
};
var profiles = new Dictionary<int, Profile>
{
[10] = new Profile(10, City: "Seoul"),
[30] = new Profile(30, City: null),
};
// Each lookup makes possible absence explicit in its return type.
Option<User> FindUser(int id) =>
users.TryGetValue(id, out var user) ? Option<User>.Some(user) : Option<User>.None;
Option<Profile> FindProfile(User user) =>
user.ProfileId is int pid && profiles.TryGetValue(pid, out var profile)
? Option<Profile>.Some(profile)
: Option<Profile>.None;
Option<string> CityOf(Profile profile) =>
profile.City is string city ? Option<string>.Some(city) : Option<string>.None;
// Comparison: the same lookup returning null; string? provides compiler warnings.
string? CityOrNull(int id)
{
User? user = users.GetValueOrDefault(id);
if (user?.ProfileId is not int pid) return null;
return profiles.GetValueOrDefault(pid)?.City;
}
foreach (var id in new[] { 1, 2, 3, 4 })
{
// None at any stage skips the rest and passes through to the end.
Option<string> city = FindUser(id)
.Bind(FindProfile)
.Bind(CityOf)
.Map(name => name.ToUpperInvariant());
// Choose the fallback at this one point.
string shown = city.GetValueOrDefault("(city not set)");
string viaNull = CityOrNull(id)?.ToUpperInvariant() ?? "(city not set)";
Console.WriteLine($"user {id}: {city} -> {shown} | null version: {viaNull}");
}
record User(int Id, int? ProfileId);
record Profile(int Id, string? City);
// Keep HasValue and its value in one type; the value is not directly exposed.
sealed record Option<T>
{
private readonly T value;
public bool HasValue { get; }
private Option(T value, bool hasValue) { this.value = value; HasValue = hasValue; }
public static Option<T> Some(T value) => new(value, true);
public static Option<T> None { get; } = new(default!, false);
public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) =>
HasValue ? some(value) : none();
// Apply f only to a present value, passing None through.
public Option<TOut> Map<TOut>(Func<T, TOut> f) =>
HasValue ? Option<TOut>.Some(f(value)) : Option<TOut>.None;
// When f returns Option, connect it without nesting Option<Option<TOut>>.
public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> f) =>
HasValue ? f(value) : Option<TOut>.None;
public T GetValueOrDefault(T fallback) => HasValue ? value : fallback;
public override string ToString() => HasValue ? $"Some({value})" : "None";
}user 1: Some(SEOUL) -> SEOUL | null version: SEOUL
user 2: None -> (city not set) | null version: (city not set)
user 3: None -> (city not set) | null version: (city not set)
user 4: None -> (city not set) | null version: (city not set)Both approaches return the same values, with ?. taking the place of Map and ?? the place of GetValueOrDefault. Their types differ: using CityOrNull's string? as a string without checking produces only a warning and still compiles, while Option<string> exposes no string without GetValueOrDefault or Match. Users 2, 3, and 4 all appear as None, however, so the failing stage is lost; use a result type if that distinction matters. This Option also permits Some(null), leaving the creator responsible for rejecting null when wrapping reference values.
TypeScript example
TypeScript uses a discriminated union distinguished by kind. value is available only in the branch that checks kind, letting the compiler check the cases as C# Match does. With --strict, using string | undefined as string without checking already produces an error, so Option's additions here are map and bind for connecting steps and one none value for absence.
// A discriminated union uses kind to distinguish the two cases.
type Option<T> = { readonly kind: "some"; readonly value: T } | { readonly kind: "none" };
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
const none: Option<never> = { kind: "none" };
function map<T, U>(option: Option<T>, f: (value: T) => U): Option<U> {
return option.kind === "some" ? some(f(option.value)) : none;
}
function bind<T, U>(option: Option<T>, f: (value: T) => Option<U>): Option<U> {
return option.kind === "some" ? f(option.value) : none;
}
function getValueOrDefault<T>(option: Option<T>, fallback: T): T {
return option.kind === "some" ? option.value : fallback;
}
function show<T>(option: Option<T>): string {
return option.kind === "some" ? `Some(${String(option.value)})` : "None";
}
type User = { readonly id: number; readonly profileId: number | null };
type Profile = { readonly id: number; readonly city: string | null };
const users = new Map<number, User>([
[1, { id: 1, profileId: 10 }],
[2, { id: 2, profileId: null }],
[3, { id: 3, profileId: 30 }],
]);
const profiles = new Map<number, Profile>([
[10, { id: 10, city: "Seoul" }],
[30, { id: 30, city: null }],
]);
const findUser = (id: number): Option<User> => {
const user = users.get(id);
return user === undefined ? none : some(user);
};
const findProfile = (user: User): Option<Profile> => {
const profile = user.profileId === null ? undefined : profiles.get(user.profileId);
return profile === undefined ? none : some(profile);
};
const cityOf = (profile: Profile): Option<string> =>
profile.city === null ? none : some(profile.city);
// Comparison: the same lookup returning undefined, using ?. and ??.
function cityOrUndefined(id: number): string | undefined {
const profileId = users.get(id)?.profileId;
if (profileId === undefined || profileId === null) return undefined;
return profiles.get(profileId)?.city ?? undefined;
}
for (const id of [1, 2, 3, 4]) {
// none at any stage prevents later stages from running.
const city = map(bind(bind(findUser(id), findProfile), cityOf), (name) => name.toUpperCase());
// Choose the fallback at this one point.
const shown = getValueOrDefault(city, "(city not set)");
const viaNull = cityOrUndefined(id)?.toUpperCase() ?? "(city not set)";
console.log(`user ${id}: ${show(city)} -> ${shown} | null version: ${viaNull}`);
}user 1: Some(SEOUL) -> SEOUL | null version: SEOUL
user 2: None -> (city not set) | null version: (city not set)
user 3: None -> (city not set) | null version: (city not set)
user 4: None -> (city not set) | null version: (city not set)The results match C#, and the stage that produced none is lost here too.
See also
Example environment: each C# example is a separate .NET 10 console program; compile TypeScript for ES2022 or later, then run the JavaScript with Node.js.
Footnotes
- Hoare, Tony. Null References: The Billion Dollar Mistake. InfoQ (2009). QCon talk video and introduction. ↩
- The Standard ML Basis Library: The Option structure.
datatype 'a option = NONE | SOME of 'a,getOpt,map,mapPartial. ↩ - The Haskell 98 Report, Chapter 8, Standard Prelude.
data Maybe a = Nothing | Just a,maybe,instance Monad Maybe. ↩ - Microsoft Learn. Options (F# language reference). ↩
- Rust standard library: enum Option. ↩
- Microsoft Learn. Nullable value types (C# reference). ↩
- Microsoft Learn. Nullable reference types. A compile-time feature without a change to runtime behavior; both
stringandstring?areSystem.String. ↩