Option and Escaping Null: the Maybe Type, Map, Bind and the Secret of LINQ Query Syntax
Option expresses "no value" as a type and makes you answer "what if there isn't one?" before you reach the value; nullable reference types are enough in most places, but Option pays off in chained computations and nested kinds of absence. We build Bookstore's coupon calculation with our own Option<T> using Map, Bind and Match, and show why LINQ query syntax works once Select/SelectMany exist, along with the map + flatten intuition behind monads.
Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402); the TypeScript examples with TypeScript 7.0.2 and Node.js 26.
Guess First: Two Different Kinds of "Nothing"
In Bookstore, the online bookstore used as the shared example across this site's series, campaign rates live in a dictionary keyed by coupon code. Marketing has added the VIP coupon but hasn't decided its rate yet, so its value is null. A coupon called SPRING5 doesn't exist at all. There is also an empty array of per-line discounts:
// VIP: the rate isn't decided yet
Dictionary<string, decimal?> rates = new() { ["AUTUMN10"] = 0.10m, ["VIP"] = null };
decimal? vip = rates.GetValueOrDefault("VIP");
decimal? spring = rates.GetValueOrDefault("SPRING5");
Console.WriteLine($"VIP: {vip?.ToString() ?? "null"}, SPRING5: {spring?.ToString() ?? "null"}, same: {vip == spring}");
decimal[] lineDiscounts = [];
Console.WriteLine($"first discount: {lineDiscounts.FirstOrDefault()}");
What does it print? More importantly: can code that reads the output tell "the coupon exists but has no rate" apart from "there is no such coupon"?
The Answer: null and default Say More Than One Thing
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-guess
VIP: null, SPRING5: null, same: True
first discount: 0
Two different situations land on the same value. The VIP coupon is in the catalog but its rate is unknown; SPRING5 isn't there at all. GetValueOrDefault returns null for both and the difference is gone. The empty array's FirstOrDefault() returns 0: "there are no discount lines" looks exactly like "the first line's discount is zero". At the checkout, that means telling a customer who used a coupon with no campaign behind it "discount: 0 TL" instead of "invalid coupon".
The problem isn't null itself but one value carrying several meanings. null sometimes means "not assigned yet", sometimes "not found", sometimes "not applicable" and sometimes it's the sign of a bug. That's why people keep bringing up Tony Hoare, who introduced the null reference in ALGOL W in 1965 and called it his billion-dollar mistake in a 2009 talk at QCon London. Functional languages answer by expressing "no value" as a type: option in F#, Maybe in Haskell, Option in Rust. In this post we write our own Option<T> in C# and use it in chained computations, but first let's look at the protection C# already gives us.
Where Nullable Reference Types Are Enough, and Where They Aren't
C# has had nullable reference types since C# 8 and in most cases they are enough. Campaign? and Campaign say different things, and the compiler warns when you dereference a reference that might be null without checking:
Dictionary<string, Campaign> campaigns = new();
Campaign? campaign = campaigns.GetValueOrDefault("AUTUMN10");
decimal rate = campaign.Rate;
decimal? memberDiscount = null;
decimal value = memberDiscount.Value;
Program.cs(5,16): warning CS8602: Dereference of a possibly null reference.
Program.cs(8,17): warning CS8629: Nullable value type may be null.
How to make an API more honest with attributes (NotNullWhen, MaybeNull) is covered in the C# Advanced series post "Nullable Reference Types in C# in Depth: NotNullWhen, MaybeNull and API Design". For a DTO property, a method parameter or an optional column on an entity, nullable reference types are the right tool: no extra type, and both System.Text.Json and Entity Framework Core understand them directly.
There are three places where they fall short. First, the warnings are only warnings: the build still succeeds and null is still possible at run time. Second, they can't express nested absence; there is no decimal??, which is why the two kinds of "nothing" in the guess collapsed into one value. Third, in a chained computation you have to check every step separately: clean up the code, stop if it's empty, look it up in the catalog, stop if it's missing, check whether the campaign is active, stop if it isn't, compute the discount. The ?. operator only chains member access; as soon as a dictionary lookup or a condition sits between the steps, you're back to if statements. That third case and the second one are where Option adds value.
Writing Your Own Option<T>
An Option either carries a value (Some) or carries nothing (None). It's the smallest example of the previous post's sum type: two cases, one with data and one without. Written as a struct, default(Option<T>) is automatically None:
namespace Bookstore.Ordering.Functional;
// default(Option<T>) is None: a struct with no value inside
public readonly record struct Option<T>
{
private readonly T _value;
private Option(T value) => (_value, IsSome) = (value, true);
public bool IsSome { get; }
public static Option<T> None => default;
public static Option<T> Some(T value) =>
value is null ? throw new ArgumentNullException(nameof(value)) : new Option<T>(value);
public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) => IsSome ? some(_value) : none();
public Option<TOut> Map<TOut>(Func<T, TOut> map) => IsSome ? Option<TOut>.Some(map(_value)) : Option<TOut>.None;
public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> bind) => IsSome ? bind(_value) : Option<TOut>.None;
public Option<T> Where(Func<T, bool> predicate) => IsSome && predicate(_value) ? this : None;
public T GetValueOrDefault(T fallback) => IsSome ? _value : fallback;
public override string ToString() => IsSome ? $"Some({_value})" : "None";
}
A few decisions are deliberate. The value is private; the only ways to reach it are Match, Map, Bind or GetValueOrDefault with a fallback. In other words, you can't get at the value without answering "what happens if there isn't one?". Some(null) throws; otherwise an Option carrying null inside Some would bring back the ambiguity we're trying to remove. Because it's a record struct, equality comes from the compiler; two Nones are equal. We'll come back to the performance side of struct versus class, with measurements, in the last post of the series.
Option<decimal> some = Option.Some(0.10m);
Option<decimal> none = Option<decimal>.None;
Option<decimal> fromDefault = default;
Console.WriteLine($"{some}, {none}, default: {fromDefault}, equal: {none == fromDefault}");
Console.WriteLine(some.Match(rate => $"rate {rate}", () => "no rate"));
Console.WriteLine(none.Match(rate => $"rate {rate}", () => "no rate"));
Console.WriteLine($"map: {some.Map(rate => rate * 100)}, {none.Map(rate => rate * 100)}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-basics
Some(0.10), None, default: None, equal: True
rate 0.10
no rate
map: Some(10.00), None
Map applies a function to the value inside and returns the result as an Option again; a None stays None and the function is never called. To connect it to existing code we add a few small helpers: ToOption turns a nullable value into an Option, Find returns "not found" from a dictionary as None, and the LINQ methods we'll get to shortly:
public static class Option
{
public static Option<T> Some<T>(T value) => Option<T>.Some(value);
public static Option<T> ToOption<T>(this T? value) where T : class =>
value is null ? Option<T>.None : Option<T>.Some(value);
public static Option<T> ToOption<T>(this T? value) where T : struct =>
value.HasValue ? Option<T>.Some(value.Value) : Option<T>.None;
public static Option<TValue> Find<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> source, TKey key) =>
source.TryGetValue(key, out TValue? value) ? Option<TValue>.Some(value) : Option<TValue>.None;
}
The two ToOption overloads differ not only in their constraints but also in their parameter types (T and Nullable<T>), which is why C# allows both.
Map and Bind: From Coupon Code to Discount
Now let's write the checkout's coupon calculation with Option. The steps: clean up what the customer typed (trim it, upper-case it, give up if it's empty), find the campaign in the catalog, check that it's active today, compute the discount if it is, and use zero if nothing came out:
public static class CouponLookup
{
public static Option<string> NormalizeCode(string? code) =>
code.ToOption()
.Map(text => text.Trim().ToUpperInvariant())
.Where(text => text.Length > 0);
public static Option<Campaign> ActiveCampaign(
string? code, IReadOnlyDictionary<string, Campaign> campaigns, DateTimeOffset now) =>
NormalizeCode(code)
.Bind(normalized => campaigns.Find(normalized))
.Where(campaign => campaign.IsActiveAt(now));
public static decimal CouponDiscount(
decimal subtotal, string? code, IReadOnlyDictionary<string, Campaign> campaigns, DateTimeOffset now) =>
ActiveCampaign(code, campaigns, now)
.Map(campaign => Math.Round(subtotal * campaign.Rate, 2, MidpointRounding.AwayFromZero))
.GetValueOrDefault(0m);
}
Each line does one thing and none of them checks for null. This is where the difference between Map and Bind shows. Trim().ToUpperInvariant() always returns a string, so it's Map. campaigns.Find(...) returns an Option itself; calling it with Map would leave us holding an Option<Option<Campaign>>. Bind runs the function and returns the resulting Option directly, flattening the nesting. The rule is short: if the function returns a plain value, use Map; if it returns an Option, use Bind.
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-chain
"AUTUMN10" campaign: Some(AUTUMN10) discount: 24.94
" autumn10 " campaign: Some(AUTUMN10) discount: 24.94
"SUMMER15" campaign: None discount: 0
"SPRING5" campaign: None discount: 0
"" campaign: None discount: 0
null campaign: None discount: 0
This output also answers the exercise from the third post: a coupon typed in lower case is now accepted by a business rule (NormalizeCode). In mid-November SUMMER15 is in the catalog but not active; SPRING5 isn't in the catalog; an empty string and a null code are never looked up. Four different reasons for "nothing" lead to the same result, a zero discount. This time that's a conscious decision: GetValueOrDefault(0m) at the end of the chain makes the "zero if missing" call in exactly one place. If the screen needed to tell "invalid coupon" from "expired coupon", Option would no longer be enough; we'd need a type that carries why the value is missing, which is Result.
Nesting Options also handles the two kinds of "nothing" from the guess. Dune is in the catalog but its price hasn't been entered yet; let's keep Option<decimal> values in the price list:
// Dune is in the catalog but its price isn't set yet
Dictionary<string, Option<decimal>> listPrices = new()
{
["9780141439587"] = Option.Some(59.90m),
["9780441013593"] = Option<decimal>.None
};
string[] isbns = ["9780141439587", "9780441013593", "9780000000000"];
foreach (string isbn in isbns)
{
Option<Option<decimal>> nested = listPrices.Find(isbn);
Option<decimal> flat = listPrices.Find(isbn).Bind(price => price);
string meaning = nested.Match(
price => price.Match(value => $"price {value}", () => "in catalog, no price yet"),
() => "not in catalog");
Console.WriteLine($"{isbn}: {nested,-15} {flat,-12} {meaning}");
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-nested
9780141439587: Some(Some(59.90)) Some(59.90) price 59.90
9780441013593: Some(None) None in catalog, no price yet
9780000000000: None None not in catalog
Some(None) and None are different values; the distinction we couldn't express with decimal? is right there. When you don't need the distinction, Bind(price => price) collapses the two layers into one. That collapsing is called flatten, and it's the subject of the next section.
The Secret of LINQ Query Syntax: Select and SelectMany
LINQ query syntax isn't only for IEnumerable<T>. According to the query expressions chapter of the C# language specification, the compiler translates from ... select into method calls named Select, SelectMany, Where and so on, without caring which type defines them. As long as methods with the right signatures exist, query syntax works on any type. For Option, two extensions are enough:
// LINQ query syntax looks for methods with these names and shapes
public static Option<TOut> Select<T, TOut>(this Option<T> option, Func<T, TOut> map) => option.Map(map);
public static Option<TOut> SelectMany<T, TMid, TOut>(
this Option<T> option, Func<T, Option<TMid>> bind, Func<T, TMid, TOut> project) =>
option.Bind(first => bind(first).Map(second => project(first, second)));
Where is already a method on Option. The same coupon calculation can now be written like this:
// The same chain in LINQ query syntax
public static Option<decimal> CouponDiscountQuery(
decimal subtotal, string? code, IReadOnlyDictionary<string, Campaign> campaigns, DateTimeOffset now) =>
from normalized in NormalizeCode(code)
from campaign in campaigns.Find(normalized)
where campaign.IsActiveAt(now)
select Math.Round(subtotal * campaign.Rate, 2, MidpointRounding.AwayFromZero);
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-query
" autumn10 " query: Some(24.94) method: 24.94
"SUMMER15" query: None method: 0
null query: None method: 0
The compiler turns the second from into SelectMany, where into Where and select into Select; written by hand it's roughly equivalent to this:
Option<decimal> translated = CouponLookup.NormalizeCode(code)
.SelectMany(normalized => campaigns.Find(normalized), (normalized, campaign) => new { normalized, campaign })
.Where(pair => pair.campaign.IsActiveAt(now))
.Select(pair => Math.Round(subtotal * pair.campaign.Rate, 2, MidpointRounding.AwayFromZero));
The advantage of query syntax is that every step can see the variables of the earlier steps (normalized, campaign); with a method chain you'd have to nest lambdas to get the same. A test also checks that both forms give the same result (Query_syntax_matches_the_method_chain). How SelectMany behaves on collections is covered in the LINQ series post "Projection with LINQ: Select, SelectMany and Anonymous Types".
The Same Shape Everywhere: Map and Flatten
SelectMany does the same job on collections: each order yields a list of ISBNs; with Select we'd get a list of lists, SelectMany brings them down to one list. With Option, Map produces a nested Option and Bind flattens it:
IEnumerable<string> isbns =
from order in new[] { new[] { "9780441013593", "9780141439587" }, new[] { "9780261103344" } }
from isbn in order
select isbn;
Console.WriteLine($"IEnumerable: {string.Join(", ", isbns)}");
Option<decimal> rate =
from code in CouponLookup.NormalizeCode("autumn10")
from campaign in Campaigns.Find(code)
select campaign.Rate;
Console.WriteLine($"Option: {rate}");
Console.WriteLine($"Task: {await AfterMemberDiscountAsync()}");
// await unwraps the Task the way Bind unwraps an Option
private static async Task<decimal> AfterMemberDiscountAsync() => await LoadSubtotalAsync() - 15m;
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-shapes
IEnumerable: 9780441013593, 9780141439587, 9780261103344
Option: Some(0.10)
Task: 234.40
This shared shape is called a monad: a type that wraps a value (Option, a list, a Task), a Map that applies a function to the wrapped value, and a Bind that chains a function returning the wrapper type without nesting it. In Haskell the operation is >>=, in F# it's let! inside computation expressions, and in C# query syntax it's the second from. The mathematical definition comes with a few laws (for example, Some(x).Bind(f) must give the same result as f(x)), but for everyday code the intuition is enough: work with the wrapped value, don't nest the wrapper.
| Type | Map equivalent | Bind (map + flatten) equivalent |
|---|---|---|
Option<T> | Map | Bind |
IEnumerable<T> | Select | SelectMany |
Task<T> | returning a plain value after await | awaiting a call that returns a Task after await |
TypeScript Array | map | flatMap |
Promise | returning a plain value inside then | returning a Promise inside then |
RxJS Observable | map | mergeMap, switchMap, concatMap |
One interesting detail in this table is that Promise.then plays both roles: whether the callback returns a plain value or a Promise, the result is a single-layer Promise, because JavaScript flattens automatically. The difference between RxJS's three flattening operators (parallel, cancelling, sequential) belongs to the upcoming RxJS series.
In TypeScript: undefined, ?. and ?? Are Usually Enough
In TypeScript with strictNullChecks on, string | undefined is already a type the compiler tracks, and optional chaining (?.) and nullish coalescing (??) keep chained access short. Most Angular components don't need a separate Option type:
type Customer = { readonly email: string; readonly address?: { readonly city?: string } };
const ada: Customer = { email: "ada@example.com", address: { city: "Izmir" } };
const alan: Customer = { email: "alan@example.com" };
// Most of the time optional chaining and ?? are enough
for (const customer of [ada, alan]) {
console.log(customer.email, "->", customer.address?.city?.toUpperCase() ?? "no city");
}
The ambiguity from the guess exists in TypeScript too: Map.get returns undefined both for "no such key" and for "the value is undefined". When that distinction really matters, a small discriminated union is enough:
export type Option<T> = { readonly kind: "some"; readonly value: T } | { readonly kind: "none" };
export const some = <T>(value: T): Option<T> => ({ kind: "some", value });
export const none: Option<never> = { kind: "none" };
export const map = <T, U>(option: Option<T>, f: (value: T) => U): Option<U> =>
option.kind === "some" ? some(f(option.value)) : none;
export const bind = <T, U>(option: Option<T>, f: (value: T) => Option<U>): Option<U> =>
option.kind === "some" ? f(option.value) : none;
export const getOrElse = <T>(option: Option<T>, fallback: T): T =>
option.kind === "some" ? option.value : fallback;
export function find<K, V>(source: ReadonlyMap<K, V>, key: K): Option<V> {
return source.has(key) ? some(source.get(key) as V) : none;
}
// VIP: the rate isn't decided yet
const rates = new Map<string, number | undefined>([
["AUTUMN10", 0.1],
["VIP", undefined],
]);
console.log("get:", rates.get("VIP"), rates.get("SPRING5"), rates.get("VIP") === rates.get("SPRING5"));
// Dune is in the catalog but its price isn't set yet
const listPrices = new Map<string, Option<number>>([
["9780141439587", some(59.9)],
["9780441013593", none],
]);
for (const isbn of ["9780141439587", "9780441013593", "9780000000000"]) {
const nested = find(listPrices, isbn);
const flat = bind(nested, (price) => price);
console.log(isbn, JSON.stringify(nested), getOrElse(map(flat, (p) => p * 0.9), 0).toFixed(2));
}
$ node src/06-option.ts
ada@example.com -> IZMIR
alan@example.com -> no city
get: undefined undefined true
9780141439587 {"kind":"some","value":{"kind":"some","value":59.9}} 53.91
9780441013593 {"kind":"some","value":{"kind":"none"}} 0.00
9780000000000 {"kind":"none"} 0.00
An Option type buys less in TypeScript than in C#: undefined is already part of the type and the compiler reports unchecked access as an error, not a warning. It's worth using where "nothing" has more than one meaning and in long transformation chains. At the library level, libraries such as Effect do this job; that's outside the scope of this series, so we only mention it.
Common Pitfalls
Using Option everywhere and running two systems alongside nullable reference types. When one method returns Customer? and another returns Option<Customer>, the team starts checking "which one was this?" on every call and conversion code piles up between the two systems. A sensible line: nullable reference types in DTOs, entities, parameters and public APIs; Option in the chained computations inside the core. Enter with ToOption() at the boundary, leave with GetValueOrDefault/Match.
Adding a Value property and bringing null back. It's tempting to add public T Value => IsSome ? _value : throw ... because "sometimes I know there really is a value". That's a repeat of Nullable<T>.Value:
decimal? rate = null;
try
{
#pragma warning disable CS8629 // NRT warns here; disabled for the demo
Console.WriteLine(rate.Value);
#pragma warning restore CS8629
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 06-pitfalls
json: {"isSome":true}
InvalidOperationException: Nullable object must have a value.
The moment you add Value, Option's only benefit, not being able to reach the value without answering "what if there isn't one?", is gone. Teammates start writing if (option.IsSome) option.Value and the code turns into something longer than the nullable version but no safer.
Sending an Option to JSON or to the database. Look at the first line of the output: serializing Some(0.10m) gives {"isSome":true}. The value is private, so System.Text.Json doesn't see it and the rate disappears silently, without even an exception. EF Core can't map this type to a column either. Option is a type that stays inside the system; turn it into a nullable property at the API boundary and at the database boundary. If you really have to carry it, write a JSON converter, but first ask why decimal? in the DTO isn't enough.
Taking Option as a parameter. Checkout(Cart cart, Option<string> coupon) forces every caller to write Option.Some("AUTUMN10"). Accepting string? and calling ToOption() inside the method makes life easier for callers; Option is most useful as a return type.
Trying to turn every FirstOrDefault into an Option. For reference types, FirstOrDefault plus nullable reference types already says "nothing" honestly with null. You need Option where the default value collides with a valid value (0, false, an empty struct), as in the guess, or where "nothing" has nested meanings. The code has a small helper called TryFirst for this.
A Small Experiment
★☆☆ Add OrElse(Func<Option<T>> alternative) to Option<T>: if the coupon yields no campaign, try the customer's membership campaign. Write the ActiveCampaign(...).OrElse(...) chain and explain how it differs from Map/Bind.
★☆☆ Find the first printed book in the cart lines with TryFirst. If you do the same with FirstOrDefault, what comes back when the cart has no printed books at all, and what does TryFirst return?
★★☆ Write ParseQuantity(string text): Option<int> that reads the quantity from the form (int.TryParse and Where(q => q > 0)). Then build a line-total query in the form from quantity in ParseQuantity(text) from price in listPrices.Find(isbn) .... What happens when the ISBN is in the catalog but its price hasn't been entered?
★★★ Write Option<T> as a sealed class instead of a struct (with Some and None as two subclasses), call CouponDiscount 1,000,000 times and compare the allocations of the two versions with GC.GetAllocatedBytesForCurrentThread(). We'll do the same measurement with BenchmarkDotNet in the last post of the series.
Option says "there is no value" but not why. In the next post we move to the type that carries that information too: Result. We'll build the order flow with Map, Bind, Ensure, Tap and Match, look at the two-track model of railway-oriented programming and see how to accumulate errors so that a form returns all of its validation errors at once.