Part II: The Option Pattern
"I call it my billion-dollar mistake." -- Tony Hoare, inventor of null, 2009
Tony Hoare was not being dramatic. He was being conservative. The null reference, introduced in ALGOL W in 1965 because it was "so easy to implement," has caused more production outages, more defensive boilerplate, and more NullReferenceException stack traces than any other single language feature in the history of software. Every language that inherited the concept -- C, C++, Java, C#, JavaScript -- inherited the same fundamental design flaw: a value that inhabits every reference type, that the compiler cannot distinguish from a real value, and that detonates at runtime when you least expect it.
C# has made several attempts to mitigate the problem. Nullable<T> for value types was a good start, but it only covers int?, bool?, and their kin -- it does nothing for reference types. Nullable reference types (NRTs), introduced in C# 8, are a much larger effort, but they are advisory. They are compiler warnings, not compiler errors. They can be suppressed with !. They have no runtime representation. They do not change the fact that any string variable can still be null at runtime, and the CLR will not stop you.
The Option pattern takes a different approach. Instead of trying to make null safer, it removes null from the equation entirely. An Option<T> is either Some(value) or None. There is no third state. There is no way to access the value without first acknowledging that it might not be there. The compiler enforces this. The type system enforces this. And when you compose Options through pipelines of Map, Bind, Filter, and Match, the entire chain is null-safe by construction -- not by discipline, not by convention, not by hoping that every developer on the team remembers to check.
This chapter covers the complete FrenchExDev.Net.Options package: the core Option<T> sealed record, the static factory helpers, the full extension landscape, async pipelines on Task<Option<T>>, LINQ query syntax integration, collection extensions including Haskell-inspired Sequence and Traverse, bidirectional conversion with Result<T>, and the OptionAssertions testing utilities.
The Problem with Null
Consider a typical C# method that looks up a user and retrieves their shipping address:
public string GetShippingCity(int userId)
{
var user = _userRepository.FindById(userId);
if (user != null)
{
if (user.ShippingAddress != null)
{
if (user.ShippingAddress.City != null)
{
return user.ShippingAddress.City;
}
}
}
return "Unknown";
}public string GetShippingCity(int userId)
{
var user = _userRepository.FindById(userId);
if (user != null)
{
if (user.ShippingAddress != null)
{
if (user.ShippingAddress.City != null)
{
return user.ShippingAddress.City;
}
}
}
return "Unknown";
}This is a pattern every C# developer has written hundreds of times. Three levels of nesting, three null checks, and a fallback value buried at the bottom. The code works, but it has problems that go deeper than aesthetics.
Problem 1: NullReferenceException at runtime. If you forget one of those null checks -- or if a future developer adds a new code path that bypasses them -- you get a NullReferenceException in production. The compiler does not warn you. The type system does not help you. The error manifests at the worst possible time: when a real user hits the code path you did not test.
// This compiles. This passes code review. This crashes at 2 AM.
public string GetShippingCity(int userId)
{
var user = _userRepository.FindById(userId);
return user.ShippingAddress.City; // NullReferenceException
}// This compiles. This passes code review. This crashes at 2 AM.
public string GetShippingCity(int userId)
{
var user = _userRepository.FindById(userId);
return user.ShippingAddress.City; // NullReferenceException
}Problem 2: Ambiguous API contracts. When a method returns null, what does it mean? Consider these three methods:
User? FindUserById(int id); // null = "not found"
User? GetCurrentUser(); // null = "not authenticated"
User? DeserializeUser(string json); // null = "invalid JSON"User? FindUserById(int id); // null = "not found"
User? GetCurrentUser(); // null = "not authenticated"
User? DeserializeUser(string json); // null = "invalid JSON"All three return User?, but null means something different in each case. The return type does not communicate the reason for absence. The caller must read documentation (if it exists), read the implementation (if they have access), or guess (if they are in a hurry). This is not a type system -- it is a trust system.
Problem 3: No compiler-enforced exhaustive handling. When you receive a User?, the compiler does not force you to handle the null case. You can assign it to a non-nullable variable, call methods on it, pass it to another method -- all without a single warning in many configurations. Nullable reference types improve this, but they are opt-in, suppressible, and have well-known limitations with generics, interfaces, and third-party code.
// NRTs: the compiler warns, but you can suppress with !
User user = FindUserById(42)!; // "I know better than the compiler"
Console.WriteLine(user.Name); // NullReferenceException if you were wrong// NRTs: the compiler warns, but you can suppress with !
User user = FindUserById(42)!; // "I know better than the compiler"
Console.WriteLine(user.Name); // NullReferenceException if you were wrongThe fundamental issue is that null is a value that pretends to be a T but is not a T. It passes type checks, it satisfies generic constraints, and it explodes when you treat it like the T it claims to be. The Option pattern replaces this pretender with an honest type: Option<T> either contains a T or it does not, and you must deal with both cases before you can proceed.
Option<T>: A Sealed Record
The core type in FrenchExDev.Net.Options is:
public sealed record Option<T> where T : notnullpublic sealed record Option<T> where T : notnullThree design choices are encoded in that single line, and each one is deliberate.
Why sealed? A sealed class cannot be subclassed. This means the set of possible states is closed: Option<T> is either Some or None, and no downstream code can introduce a third variant. This is critical for exhaustive pattern matching. If Option<T> were not sealed, a developer could create class MaybeSome<T> : Option<T> and break every Match call in the codebase. Sealing the type guarantees that the two-branch match covers all possibilities -- not just today, but forever.
Why record? Records in C# provide three things that matter here. First, value equality: Option.Some(42) == Option.Some(42) is true, because records compare by value, not by reference. This makes Options safe to use as dictionary keys, in LINQ Distinct() calls, and in assertions. Second, immutability: once created, an Option cannot be mutated. There is no SetValue() method, no mutable state, no race conditions. Third, pattern-matching friendliness: records work naturally with C# switch expressions and is patterns, although we provide Match and Switch methods that are more expressive.
Why where T : notnull? This is the constraint that closes the escape hatch. Without it, you could write Option<string?>, which would mean "an optional value that might itself be null" -- defeating the entire purpose. The notnull constraint ensures that if you have a Some, the value inside is never null. Option<T> represents the presence or absence of a value; it does not represent "a value that might also be null." That distinction is the entire point.
Some and None
Creating an Option is straightforward:
// Explicit factory methods on Option<T>
var some = Option<int>.Some(42);
var none = Option<int>.None();
// Static helper class (shorter, avoids repeating the type parameter)
var some2 = Option.Some(42); // infers Option<int>
var none2 = Option.None<int>(); // explicit type parameter required for None// Explicit factory methods on Option<T>
var some = Option<int>.Some(42);
var none = Option<int>.None();
// Static helper class (shorter, avoids repeating the type parameter)
var some2 = Option.Some(42); // infers Option<int>
var none2 = Option.None<int>(); // explicit type parameter required for NoneThe Option<T>.Some(T value) factory throws ArgumentNullException if you pass null. This is the only place in the entire Option API where an exception is thrown by design -- it guards the invariant that Some always contains a non-null value. Once past this gate, every downstream operation can rely on the value being present without checking.
The Option<T>.None() factory returns a singleton-like instance representing absence. It carries no value, no error message, no reason for absence. If you need to communicate why something is absent, use Result<T> instead -- that is the boundary between "not found" and "failed."
Implicit Conversion
For ergonomic usage, Option<T> provides an implicit conversion from T:
public static implicit operator Option<T>(T value)public static implicit operator Option<T>(T value)This means you can write:
Option<string> name = "Alice"; // implicitly wraps in Some("Alice")Option<string> name = "Alice"; // implicitly wraps in Some("Alice")This is particularly useful in method return types:
public Option<User> FindUser(int id)
{
var user = _db.Users.FirstOrDefault(u => u.Id == id);
if (user is not null)
return user; // implicit conversion to Some(user)
return Option<User>.None();
}public Option<User> FindUser(int id)
{
var user = _db.Users.FirstOrDefault(u => u.Id == id);
if (user is not null)
return user; // implicit conversion to Some(user)
return Option<User>.None();
}From, FromNullable, and FromTry
The static Option helper class provides several factory methods for bridging between nullable code and the Option world:
// From: converts a nullable reference type to Option<T>
// null becomes None, non-null becomes Some
string? maybeName = GetNameOrNull();
Option<string> name = Option.From(maybeName);
// FromNullable: converts a nullable value type to Option<T>
int? maybeAge = GetAgeOrNull();
Option<int> age = Option.FromNullable(maybeAge);
// FromTry: wraps a function that might throw into an Option
// Exception → None, Success → Some
Option<int> parsed = Option.FromTry(() => int.Parse(input));
// FromTry with specific exception type
Option<Config> config = Option.FromTry<Config, FileNotFoundException>(
() => LoadConfig("settings.json")
);// From: converts a nullable reference type to Option<T>
// null becomes None, non-null becomes Some
string? maybeName = GetNameOrNull();
Option<string> name = Option.From(maybeName);
// FromNullable: converts a nullable value type to Option<T>
int? maybeAge = GetAgeOrNull();
Option<int> age = Option.FromNullable(maybeAge);
// FromTry: wraps a function that might throw into an Option
// Exception → None, Success → Some
Option<int> parsed = Option.FromTry(() => int.Parse(input));
// FromTry with specific exception type
Option<Config> config = Option.FromTry<Config, FileNotFoundException>(
() => LoadConfig("settings.json")
);Option.From<T>(T? value) is the bridge you use at the boundary between nullable code (third-party libraries, database queries, legacy APIs) and Option-based code. Everything inside the boundary is null-free; everything outside can be adapted with a single From call.
Option.FromTry<T>(Func<T> factory) is the bridge between exception-based code and Option-based code. If the factory throws any exception, the result is None. If you want to catch only a specific exception type, use the two-type-parameter overload FromTry<T, TException>. Other exceptions propagate normally -- this is not a catch-all swallower.
ToString
Options have a clean string representation:
Option.Some(42).ToString(); // "Some(42)"
Option.None<int>().ToString(); // "None"
Option.Some("hello").ToString(); // "Some(hello)"Option.Some(42).ToString(); // "Some(42)"
Option.None<int>().ToString(); // "None"
Option.Some("hello").ToString(); // "Some(hello)"This is useful in logging, debugging, and test failure messages. You always know at a glance whether you are looking at a Some or a None, and what value the Some contains.
Class Diagram
The diagram shows the three-part structure: the core Option<T> sealed record holds the state and matching methods, the static Option class provides convenient factory methods with type inference, and OptionExtensions provides the functional pipeline operations as extension methods. This separation keeps the core type clean while making the full API discoverable through IntelliSense.
Exhaustive Pattern Matching
The most important methods on Option<T> are Match and Switch. They are the primary way to extract a value from an Option, and they enforce exhaustive handling at compile time.
Match: Returning a Value
TResult Match<TResult>(Func<T, TResult> onSome, Func<TResult> onNone)TResult Match<TResult>(Func<T, TResult> onSome, Func<TResult> onNone)Match takes two functions: one for the Some case and one for the None case. Both must return the same type. The compiler enforces that you provide both branches -- you cannot handle Some without handling None.
Option<User> user = FindUser(42);
string greeting = user.Match(
onSome: u => $"Hello, {u.Name}!",
onNone: () => "Hello, stranger!"
);Option<User> user = FindUser(42);
string greeting = user.Match(
onSome: u => $"Hello, {u.Name}!",
onNone: () => "Hello, stranger!"
);This is the fundamental difference between Option and null. With null, you can forget to check. With Option, the type system requires both branches. If you delete the onNone parameter, the code does not compile. If you delete the onSome parameter, the code does not compile. There is no way to accidentally ignore either case.
Match is particularly powerful when combined with switch expressions over enums or other discriminated types, because every path through the code must produce a value:
string status = FindOrder(orderId).Match(
onSome: order => order.Status switch
{
OrderStatus.Pending => "Awaiting payment",
OrderStatus.Shipped => $"Shipped on {order.ShipDate}",
OrderStatus.Delivered => "Delivered",
_ => "Unknown status"
},
onNone: () => "Order not found"
);string status = FindOrder(orderId).Match(
onSome: order => order.Status switch
{
OrderStatus.Pending => "Awaiting payment",
OrderStatus.Shipped => $"Shipped on {order.ShipDate}",
OrderStatus.Delivered => "Delivered",
_ => "Unknown status"
},
onNone: () => "Order not found"
);Switch: Side Effects
void Switch(Action<T> onSome, Action onNone)void Switch(Action<T> onSome, Action onNone)Switch is the void-returning counterpart to Match. Use it when you need to perform side effects rather than compute a value:
Option<User> user = FindUser(42);
user.Switch(
onSome: u => _logger.LogInformation("Found user {Name}", u.Name),
onNone: () => _logger.LogWarning("User 42 not found")
);Option<User> user = FindUser(42);
user.Switch(
onSome: u => _logger.LogInformation("Found user {Name}", u.Name),
onNone: () => _logger.LogWarning("User 42 not found")
);Why two methods? Because the intent is different, and conflating them leads to bugs. Match is a pure transformation: Option in, value out. It encourages functional composition. Switch is an effectful operation: Option in, side effects out. Separating them makes the code's intent explicit. When you see Match, you know the result is being used. When you see Switch, you know the result is a side effect.
Consider what happens if you only had Match:
// Awkward: using Match for side effects requires returning a dummy value
user.Match(
onSome: u => { _logger.LogInformation("Found {Name}", u.Name); return 0; },
onNone: () => { _logger.LogWarning("Not found"); return 0; }
);// Awkward: using Match for side effects requires returning a dummy value
user.Match(
onSome: u => { _logger.LogInformation("Found {Name}", u.Name); return 0; },
onNone: () => { _logger.LogWarning("Not found"); return 0; }
);That return 0 is noise. Switch eliminates it.
Properties: IsSome and IsNone
For simple conditional logic where a full Match is overkill, Option<T> exposes IsSome and IsNone boolean properties:
if (option.IsSome)
{
// option.Value is safe here -- but Match is still preferred
Console.WriteLine(option.Value);
}if (option.IsSome)
{
// option.Value is safe here -- but Match is still preferred
Console.WriteLine(option.Value);
}Accessing .Value on a None throws InvalidOperationException. This is intentional: if you bypass Match/Switch and access .Value directly, you are taking responsibility for checking IsSome first. The properties exist for interop scenarios and conditional branching where Match would be unnecessarily verbose, but they are not the recommended path.
The Extension Landscape
The core Option<T> type provides state and matching. The real power comes from the extension methods in OptionExtensions, which let you build pipelines that transform, filter, and compose Options without ever unwrapping them manually.
Map: Transforming the Inner Value
Option<TOut> Map<T, TOut>(this Option<T> option, Func<T, TOut> mapper)Option<TOut> Map<T, TOut>(this Option<T> option, Func<T, TOut> mapper)Map applies a function to the value inside a Some, producing a new Some with the transformed value. If the Option is None, the function is not called and None is returned.
Option<User> user = FindUser(42);
// Transform User to string -- only runs if user is Some
Option<string> email = user.Map(u => u.Email);
// Chain multiple Maps
Option<string> domain = user
.Map(u => u.Email)
.Map(e => e.Split('@')[1])
.Map(d => d.ToUpperInvariant());Option<User> user = FindUser(42);
// Transform User to string -- only runs if user is Some
Option<string> email = user.Map(u => u.Email);
// Chain multiple Maps
Option<string> domain = user
.Map(u => u.Email)
.Map(e => e.Split('@')[1])
.Map(d => d.ToUpperInvariant());Map is the workhorse of Option pipelines. It says: "If there is a value, transform it. If there is not, propagate the absence." No null checks, no conditionals, no nesting. The pipeline reads top to bottom, left to right, and every step is a simple function.
Think of Map as the Option equivalent of the ?. null-conditional operator, but composable and type-safe:
// Null-conditional (fragile, no type safety on the chain)
string? domain = user?.Email?.Split('@')[1]?.ToUpperInvariant();
// Option Map (composable, type-safe at every step)
Option<string> domain = user
.Map(u => u.Email)
.Map(e => e.Split('@')[1])
.Map(d => d.ToUpperInvariant());// Null-conditional (fragile, no type safety on the chain)
string? domain = user?.Email?.Split('@')[1]?.ToUpperInvariant();
// Option Map (composable, type-safe at every step)
Option<string> domain = user
.Map(u => u.Email)
.Map(e => e.Split('@')[1])
.Map(d => d.ToUpperInvariant());The key difference: the null-conditional chain produces a string? that you still need to check. The Option chain produces an Option<string> that forces you to check.
Bind / Then: Chaining Option-Returning Functions
Option<TOut> Bind<T, TOut>(this Option<T> option, Func<T, Option<TOut>> binder)Option<TOut> Bind<T, TOut>(this Option<T> option, Func<T, Option<TOut>> binder)Bind is like Map, but the function itself returns an Option<TOut> instead of a TOut. This is essential when the transformation can fail -- when the function might return None.
Option<User> user = FindUser(42);
// FindAddress returns Option<Address>, not Address
Option<Address> address = user.Bind(u => FindAddress(u.AddressId));
// Chain Bind calls for multi-step lookups
Option<string> city = FindUser(42)
.Bind(u => FindAddress(u.AddressId))
.Bind(a => FindCity(a.CityCode));Option<User> user = FindUser(42);
// FindAddress returns Option<Address>, not Address
Option<Address> address = user.Bind(u => FindAddress(u.AddressId));
// Chain Bind calls for multi-step lookups
Option<string> city = FindUser(42)
.Bind(u => FindAddress(u.AddressId))
.Bind(a => FindCity(a.CityCode));Without Bind, you would need Map followed by a flatten, because Map with an Option-returning function produces Option<Option<Address>> -- a nested Option that is useless. Bind flattens automatically.
Then is an alias for Bind:
Option<string> city = FindUser(42)
.Then(u => FindAddress(u.AddressId))
.Then(a => FindCity(a.CityCode));Option<string> city = FindUser(42)
.Then(u => FindAddress(u.AddressId))
.Then(a => FindCity(a.CityCode));The alias exists because "then" reads more naturally in imperative-style chains: "find the user, then find their address, then find their city." Use whichever name communicates your intent more clearly to your team. Bind is the standard functional programming term; Then is the pragmatic alternative.
Filter: Conditional Some/None
Option<T> Filter<T>(this Option<T> option, Func<T, bool> predicate)Option<T> Filter<T>(this Option<T> option, Func<T, bool> predicate)Filter keeps the Some if the predicate returns true, and converts it to None if the predicate returns false. If the Option is already None, it stays None.
Option<User> activeUser = FindUser(42)
.Filter(u => u.IsActive);
// Combine with Map and Bind
Option<string> email = FindUser(42)
.Filter(u => u.IsActive)
.Filter(u => u.EmailVerified)
.Map(u => u.Email);Option<User> activeUser = FindUser(42)
.Filter(u => u.IsActive);
// Combine with Map and Bind
Option<string> email = FindUser(42)
.Filter(u => u.IsActive)
.Filter(u => u.EmailVerified)
.Map(u => u.Email);Filter is the guard in your pipeline. It says: "Even if we have a value, discard it unless it meets this condition." This replaces the pattern of extracting a value, checking a condition, and either using it or returning null:
// Without Option
var user = FindUser(42);
if (user != null && user.IsActive && user.EmailVerified)
return user.Email;
return null;
// With Option
return FindUser(42)
.Filter(u => u.IsActive)
.Filter(u => u.EmailVerified)
.Map(u => u.Email);// Without Option
var user = FindUser(42);
if (user != null && user.IsActive && user.EmailVerified)
return user.Email;
return null;
// With Option
return FindUser(42)
.Filter(u => u.IsActive)
.Filter(u => u.EmailVerified)
.Map(u => u.Email);Tap / TapNone: Side Effects Without Mutation
Option<T> Tap<T>(this Option<T> option, Action<T> action)
Option<T> TapNone<T>(this Option<T> option, Action action)Option<T> Tap<T>(this Option<T> option, Action<T> action)
Option<T> TapNone<T>(this Option<T> option, Action action)Tap executes an action on the value if the Option is Some, then returns the original Option unchanged. TapNone executes an action if the Option is None, then returns the original Option unchanged. Neither method transforms the Option -- they inject side effects into a pipeline without breaking the chain.
Option<User> user = FindUser(42)
.Tap(u => _logger.LogInformation("Found user {Id}: {Name}", u.Id, u.Name))
.TapNone(() => _logger.LogWarning("User 42 not found"))
.Filter(u => u.IsActive)
.Tap(u => _metrics.IncrementActiveUserLookup());Option<User> user = FindUser(42)
.Tap(u => _logger.LogInformation("Found user {Id}: {Name}", u.Id, u.Name))
.TapNone(() => _logger.LogWarning("User 42 not found"))
.Filter(u => u.IsActive)
.Tap(u => _metrics.IncrementActiveUserLookup());Tap is the Option equivalent of inserting a Console.WriteLine in the middle of a LINQ chain. The data flows through unchanged, but you get to observe it as it passes. This is invaluable for logging, metrics, and debugging without restructuring your pipeline.
The name "Tap" comes from the plumbing metaphor: you are tapping into the pipeline to observe the flow without diverting it.
OrDefault / OrElse / Or: Escape Hatches
Sometimes you need to leave the Option world and produce a concrete value. These three methods provide increasingly flexible ways to do that.
T OrDefault<T>(this Option<T> option, T fallback)T OrDefault<T>(this Option<T> option, T fallback)OrDefault returns the value if Some, or the fallback if None:
string name = FindUser(42)
.Map(u => u.Name)
.OrDefault("Anonymous");string name = FindUser(42)
.Map(u => u.Name)
.OrDefault("Anonymous");T OrElse<T>(this Option<T> option, Func<T> fallbackFactory)T OrElse<T>(this Option<T> option, Func<T> fallbackFactory)OrElse is like OrDefault, but the fallback is lazy -- computed only when the Option is None:
string name = FindUser(42)
.Map(u => u.Name)
.OrElse(() => GenerateGuestName()); // only called if Nonestring name = FindUser(42)
.Map(u => u.Name)
.OrElse(() => GenerateGuestName()); // only called if NoneUse OrElse when the fallback is expensive to compute (database query, API call, complex calculation). OrDefault eagerly evaluates its argument even if the Option is Some.
Option<T> Or<T>(this Option<T> option, Option<T> alternative)Option<T> Or<T>(this Option<T> option, Option<T> alternative)Or returns the original Option if it is Some, or the alternative Option if the original is None. Unlike OrDefault and OrElse, Or stays in the Option world:
Option<User> user = FindUserByEmail(email)
.Or(FindUserByUsername(username))
.Or(FindUserByPhone(phone));Option<User> user = FindUserByEmail(email)
.Or(FindUserByUsername(username))
.Or(FindUserByPhone(phone));There is also a lazy overload that takes Func<Option<T>>:
Option<User> user = FindUserByEmail(email)
.Or(() => FindUserByUsername(username)) // only called if first is None
.Or(() => FindUserByPhone(phone)); // only called if both are NoneOption<User> user = FindUserByEmail(email)
.Or(() => FindUserByUsername(username)) // only called if first is None
.Or(() => FindUserByPhone(phone)); // only called if both are NoneThis pattern is perfect for fallback chains where each lookup is progressively more expensive.
Zip: Combining Two Options
Option<(T1, T2)> Zip<T1, T2>(this Option<T1> first, Option<T2> second)Option<(T1, T2)> Zip<T1, T2>(this Option<T1> first, Option<T2> second)Zip combines two Options into a single Option of a tuple. If both are Some, the result is Some((value1, value2)). If either is None, the result is None.
Option<User> user = FindUser(42);
Option<Address> address = FindAddress(99);
Option<(User, Address)> combined = user.Zip(address);
string label = combined.Match(
onSome: pair => $"{pair.Item1.Name} at {pair.Item2.Street}",
onNone: () => "Incomplete data"
);Option<User> user = FindUser(42);
Option<Address> address = FindAddress(99);
Option<(User, Address)> combined = user.Zip(address);
string label = combined.Match(
onSome: pair => $"{pair.Item1.Name} at {pair.Item2.Street}",
onNone: () => "Incomplete data"
);There is also a selector overload that lets you project the combined values:
Option<string> label = user.Zip(address, (u, a) => $"{u.Name} at {a.Street}");Option<string> label = user.Zip(address, (u, a) => $"{u.Name} at {a.Street}");Zip is the Option equivalent of "I need both values to proceed." If either one is absent, the whole operation is absent. This replaces the nested null-check pattern:
// Without Option
if (user != null && address != null)
return $"{user.Name} at {address.Street}";
return null;
// With Option
return user.Zip(address, (u, a) => $"{u.Name} at {a.Street}");// Without Option
if (user != null && address != null)
return $"{user.Name} at {address.Street}";
return null;
// With Option
return user.Zip(address, (u, a) => $"{u.Name} at {a.Street}");Contains: Predicate Checks
bool Contains<T>(this Option<T> option, Func<T, bool> predicate)
bool Contains<T>(this Option<T> option, T value)bool Contains<T>(this Option<T> option, Func<T, bool> predicate)
bool Contains<T>(this Option<T> option, T value)Contains checks whether the Option is Some and the value satisfies a predicate (or equals a specific value):
bool isAdmin = FindUser(42).Contains(u => u.Role == Role.Admin);
bool isSpecific = FindUser(42).Map(u => u.Name).Contains("Alice");bool isAdmin = FindUser(42).Contains(u => u.Role == Role.Admin);
bool isSpecific = FindUser(42).Map(u => u.Name).Contains("Alice");Contains returns false for None -- it does not throw. This makes it safe for guard clauses and conditional logic where you do not need the value itself, just a yes/no answer.
Pipeline Flow
The diagram illustrates a typical Option pipeline. The input Option<User> flows through Map (extracting the email), Filter (validating the format), Bind (looking up the mailbox, which might fail), and finally Match (handling both outcomes). At every step, if the Option is None, the remaining steps are skipped and None propagates to the end. No null checks. No early returns. No nesting.
Async Pipelines
Modern C# applications are heavily asynchronous. Database queries, HTTP calls, file I/O -- all of these return Task<T>. The Option pattern must work seamlessly with async/await, or developers will abandon it the moment they need to call an async method.
FrenchExDev.Net.Options provides two layers of async support.
Async Methods on Option<T>
The first layer provides async overloads directly on Option<T>, where the lambda itself is async:
// MatchAsync: both branches can be async
string result = await user.MatchAsync(
onSome: async u => await FormatUserProfileAsync(u),
onNone: async () => await GetDefaultProfileAsync()
);
// MapAsync: the mapper is async
Option<Profile> profile = await user.MapAsync(
async u => await _profileService.GetProfileAsync(u.Id)
);
// BindAsync: the binder is async and returns Option
Option<Address> address = await user.BindAsync(
async u => await FindAddressAsync(u.AddressId)
);
// TapAsync: the side effect is async
Option<User> logged = await user.TapAsync(
async u => await _auditService.LogAccessAsync(u.Id)
);// MatchAsync: both branches can be async
string result = await user.MatchAsync(
onSome: async u => await FormatUserProfileAsync(u),
onNone: async () => await GetDefaultProfileAsync()
);
// MapAsync: the mapper is async
Option<Profile> profile = await user.MapAsync(
async u => await _profileService.GetProfileAsync(u.Id)
);
// BindAsync: the binder is async and returns Option
Option<Address> address = await user.BindAsync(
async u => await FindAddressAsync(u.AddressId)
);
// TapAsync: the side effect is async
Option<User> logged = await user.TapAsync(
async u => await _auditService.LogAccessAsync(u.Id)
);These methods are useful when you have an Option<T> in hand and need to apply an async operation to it.
Chaining on Task<Option<T>>
The second layer is where the real power lies. When you have a Task<Option<T>> -- which is what you get from any async method that returns an Option -- you can chain operations directly on the task without intermediate await statements:
// Without async extensions: nested awaits
var user = await FindUserAsync(42);
var email = user.Map(u => u.Email);
var verified = email.Filter(e => e.Contains("@"));
var mailbox = await verified.BindAsync(async e => await FindMailboxAsync(e));
// With async extensions: fluent chain, single await
Option<Mailbox> mailbox = await FindUserAsync(42)
.MapAsync(u => u.Email) // sync mapper on Task<Option<T>>
.WhereAsync(e => e.Contains("@")) // sync predicate on Task<Option<T>>
.BindAsync(async e => await FindMailboxAsync(e)); // async binder// Without async extensions: nested awaits
var user = await FindUserAsync(42);
var email = user.Map(u => u.Email);
var verified = email.Filter(e => e.Contains("@"));
var mailbox = await verified.BindAsync(async e => await FindMailboxAsync(e));
// With async extensions: fluent chain, single await
Option<Mailbox> mailbox = await FindUserAsync(42)
.MapAsync(u => u.Email) // sync mapper on Task<Option<T>>
.WhereAsync(e => e.Contains("@")) // sync predicate on Task<Option<T>>
.BindAsync(async e => await FindMailboxAsync(e)); // async binderThe OptionAsyncExtensions class provides overloads on Task<Option<T>> for MapAsync (sync and async mapper), BindAsync (sync and async binder), TapAsync (sync and async action), WhereAsync, MatchAsync (sync and async branches), OrDefaultAsync, and OrElseAsync.
Here is a full example -- an async user profile lookup with logging, filtering, and enrichment:
public async Task<string> GetUserDashboardAsync(int userId)
{
return await FindUserAsync(userId)
.TapAsync(u => _logger.LogInformation("Found user {Id}", u.Id))
.MapAsync(u => new { u.Name, u.Email, u.SubscriptionTier })
.WhereAsync(u => u.SubscriptionTier >= SubscriptionTier.Premium)
.BindAsync(async u =>
{
var dashboard = await _dashboardService.BuildAsync(u.Email);
return dashboard;
})
.TapAsync(d => _metrics.RecordDashboardGenerated())
.MatchAsync(
onSome: d => d.RenderHtml(),
onNone: () => "<div>No premium dashboard available</div>"
);
}public async Task<string> GetUserDashboardAsync(int userId)
{
return await FindUserAsync(userId)
.TapAsync(u => _logger.LogInformation("Found user {Id}", u.Id))
.MapAsync(u => new { u.Name, u.Email, u.SubscriptionTier })
.WhereAsync(u => u.SubscriptionTier >= SubscriptionTier.Premium)
.BindAsync(async u =>
{
var dashboard = await _dashboardService.BuildAsync(u.Email);
return dashboard;
})
.TapAsync(d => _metrics.RecordDashboardGenerated())
.MatchAsync(
onSome: d => d.RenderHtml(),
onNone: () => "<div>No premium dashboard available</div>"
);
}Notice the shape of this code. It reads like a description of what happens: find the user, log it, extract fields, filter by subscription tier, build the dashboard, record metrics, and produce HTML. The async plumbing is invisible. The absence handling is automatic. Every intermediate step operates on Task<Option<T>>, and the final MatchAsync produces the concrete result.
This is what makes async Option pipelines superior to nested if/await chains. The number of lines is roughly the same, but the cognitive structure is fundamentally different: a linear pipeline versus a tree of branches.
Mixing Sync and Async in a Chain
A common question: can you mix sync and async operations in the same chain? Yes. The MapAsync overload on Task<Option<T>> accepts both Func<T, TOut> (sync) and Func<T, Task<TOut>> (async), so you can freely mix:
Option<string> result = await FindUserAsync(42)
.MapAsync(u => u.Email) // sync: extract email
.BindAsync(async e => await ValidateEmailAsync(e)) // async: validate
.MapAsync(e => e.ToLowerInvariant()) // sync: normalize
.TapAsync(async e => await NotifyAsync(e)); // async: side effectOption<string> result = await FindUserAsync(42)
.MapAsync(u => u.Email) // sync: extract email
.BindAsync(async e => await ValidateEmailAsync(e)) // async: validate
.MapAsync(e => e.ToLowerInvariant()) // sync: normalize
.TapAsync(async e => await NotifyAsync(e)); // async: side effectThe chain automatically manages the Task wrapping and unwrapping. You never see Task<Option<Task<string>>> or any other nested-task horror. The extensions handle it.
Async OrDefault and OrElse
The async extensions include OrDefaultAsync and OrElseAsync for escaping the Task<Option<T>> world:
// OrDefaultAsync: unwrap with a fallback
string name = await FindUserAsync(42)
.MapAsync(u => u.Name)
.OrDefaultAsync("Guest");
// OrElseAsync: lazy fallback for expensive operations
string name = await FindUserAsync(42)
.MapAsync(u => u.Name)
.OrElseAsync(async () => await GenerateGuestNameAsync());// OrDefaultAsync: unwrap with a fallback
string name = await FindUserAsync(42)
.MapAsync(u => u.Name)
.OrDefaultAsync("Guest");
// OrElseAsync: lazy fallback for expensive operations
string name = await FindUserAsync(42)
.MapAsync(u => u.Name)
.OrElseAsync(async () => await GenerateGuestNameAsync());These are the async equivalents of OrDefault and OrElse. They terminate the Task<Option<T>> chain and produce a Task<T> -- the final concrete value.
Error Handling in Async Pipelines
One subtle but important behavior: if any async lambda in the chain throws an exception, the exception propagates normally through the Task. The Option extensions do not catch exceptions -- they only handle the None case. If you need to convert exceptions to None, use FromTry at the point of origin:
// This propagates the exception if GetProfileAsync throws
await FindUserAsync(42).BindAsync(u => GetProfileAsync(u.Id));
// This converts the exception to None
await FindUserAsync(42).BindAsync(async u =>
Option.FromTry(async () => await GetProfileAsync(u.Id)));// This propagates the exception if GetProfileAsync throws
await FindUserAsync(42).BindAsync(u => GetProfileAsync(u.Id));
// This converts the exception to None
await FindUserAsync(42).BindAsync(async u =>
Option.FromTry(async () => await GetProfileAsync(u.Id)));The design principle: Option handles absence, not failure. Exceptions represent failures. If you want to handle failures, convert to Result<T> first.
LINQ Query Syntax
FrenchExDev.Net.Options implements the LINQ query pattern through three extension methods in OptionLinqExtensions:
// Select = Map
Option<TOut> Select<T, TOut>(this Option<T> option, Func<T, TOut> selector)
// SelectMany = Bind (with result selector for multi-from)
Option<TResult> SelectMany<T, TIntermediate, TResult>(
this Option<T> option,
Func<T, Option<TIntermediate>> binder,
Func<T, TIntermediate, TResult> resultSelector)
// Where = Filter
Option<T> Where<T>(this Option<T> option, Func<T, bool> predicate)// Select = Map
Option<TOut> Select<T, TOut>(this Option<T> option, Func<T, TOut> selector)
// SelectMany = Bind (with result selector for multi-from)
Option<TResult> SelectMany<T, TIntermediate, TResult>(
this Option<T> option,
Func<T, Option<TIntermediate>> binder,
Func<T, TIntermediate, TResult> resultSelector)
// Where = Filter
Option<T> Where<T>(this Option<T> option, Func<T, bool> predicate)These three methods are all the C# compiler needs to enable query syntax on Option<T>. This means you can write:
Option<string> result =
from user in FindUser(42)
where user.IsActive
select user.Email;Option<string> result =
from user in FindUser(42)
where user.IsActive
select user.Email;The compiler desugars this to:
Option<string> result = FindUser(42)
.Where(user => user.IsActive)
.Select(user => user.Email);Option<string> result = FindUser(42)
.Where(user => user.IsActive)
.Select(user => user.Email);Which is equivalent to:
Option<string> result = FindUser(42)
.Filter(user => user.IsActive)
.Map(user => user.Email);Option<string> result = FindUser(42)
.Filter(user => user.IsActive)
.Map(user => user.Email);Multi-From Queries (SelectMany / Bind)
The real power of LINQ query syntax with Options emerges when you use multiple from clauses. Each from clause corresponds to a Bind operation, and the compiler threads the values through:
Option<string> shippingLabel =
from user in FindUser(42)
from address in FindAddress(user.AddressId)
from city in FindCity(address.CityCode)
select $"{user.Name}, {address.Street}, {city.Name}";Option<string> shippingLabel =
from user in FindUser(42)
from address in FindAddress(user.AddressId)
from city in FindCity(address.CityCode)
select $"{user.Name}, {address.Street}, {city.Name}";This is equivalent to:
Option<string> shippingLabel = FindUser(42)
.Bind(user => FindAddress(user.AddressId)
.Bind(address => FindCity(address.CityCode)
.Map(city => $"{user.Name}, {address.Street}, {city.Name}")));Option<string> shippingLabel = FindUser(42)
.Bind(user => FindAddress(user.AddressId)
.Bind(address => FindCity(address.CityCode)
.Map(city => $"{user.Name}, {address.Street}, {city.Name}")));The query syntax version is dramatically more readable. Each line introduces a new variable that is in scope for all subsequent lines. If any step returns None, the entire query short-circuits to None. This is the monad comprehension pattern that Haskell calls do notation and F# calls computation expressions -- except it is standard C# LINQ syntax.
Combining Where and Multiple Froms
Option<decimal> shippingCost =
from user in FindUser(42)
where user.IsActive
from address in FindAddress(user.AddressId)
where address.Country == "FR"
from rate in GetShippingRate(address.PostalCode)
select rate.Cost * 1.20m; // VAT includedOption<decimal> shippingCost =
from user in FindUser(42)
where user.IsActive
from address in FindAddress(user.AddressId)
where address.Country == "FR"
from rate in GetShippingRate(address.PostalCode)
select rate.Cost * 1.20m; // VAT includedEach where clause acts as a filter gate. If the user is not active, the rest of the query is skipped. If the address is not in France, the shipping rate lookup is skipped. The result is either Some(cost) or None, with no intermediate null checks, no early returns, and no nesting.
When to Use Query Syntax vs Method Syntax
Use query syntax when you have multiple from clauses (Bind chains) and need to reference earlier variables in later expressions. The variable scoping of query syntax makes this natural, while method syntax requires nested lambdas.
Use method syntax when you have a simple linear chain of Map, Filter, Tap, and Match. Method syntax is more concise for pipelines that do not need to reference earlier values.
Both forms compile to the same code. The choice is stylistic, not functional. Teams should pick one convention and apply it consistently -- or use the guideline below:
// Method syntax: cleaner for linear pipelines
var result = FindUser(42)
.Filter(u => u.IsActive)
.Map(u => u.Email)
.OrDefault("no-email@example.com");
// Query syntax: cleaner for multi-step lookups with shared scope
var result =
from user in FindUser(42)
from order in FindLatestOrder(user.Id)
from item in FindMostExpensiveItem(order.Id)
select new OrderSummary(user.Name, order.Date, item.Name, item.Price);// Method syntax: cleaner for linear pipelines
var result = FindUser(42)
.Filter(u => u.IsActive)
.Map(u => u.Email)
.OrDefault("no-email@example.com");
// Query syntax: cleaner for multi-step lookups with shared scope
var result =
from user in FindUser(42)
from order in FindLatestOrder(user.Id)
from item in FindMostExpensiveItem(order.Id)
select new OrderSummary(user.Name, order.Date, item.Name, item.Price);A useful heuristic: if your chain has more than one Bind, query syntax is almost always more readable. If your chain is purely Map and Filter, method syntax is almost always more concise.
One more point: LINQ query syntax with Option<T> composes with existing LINQ knowledge. Any C# developer who has written from x in list where x > 5 select x * 2 already understands the syntax. The only new concept is that the source is Option<T> instead of IEnumerable<T>, and short-circuiting produces None instead of an empty sequence.
Collection Extensions
Real applications do not deal with single Options in isolation. They deal with lists of things that might be absent, dictionaries that might not contain a key, and sequences that need to be validated as a whole. OptionCollectionExtensions provides the bridge between Option<T> and IEnumerable<T>.
Values: Extracting the Somes
IEnumerable<T> Values<T>(this IEnumerable<Option<T>> options)IEnumerable<T> Values<T>(this IEnumerable<Option<T>> options)Values filters a sequence of Options, keeping only the Some values and unwrapping them:
var options = new[]
{
Option.Some("Alice"),
Option.None<string>(),
Option.Some("Bob"),
Option.None<string>(),
Option.Some("Charlie")
};
IEnumerable<string> names = options.Values();
// ["Alice", "Bob", "Charlie"]var options = new[]
{
Option.Some("Alice"),
Option.None<string>(),
Option.Some("Bob"),
Option.None<string>(),
Option.Some("Charlie")
};
IEnumerable<string> names = options.Values();
// ["Alice", "Bob", "Charlie"]This is the equivalent of .Where(o => o.IsSome).Select(o => o.Value), but safer and more expressive. It is the standard catMaybes operation from Haskell.
FirstOrNone and SingleOrNone
Option<T> FirstOrNone<T>(this IEnumerable<Option<T>> options)
Option<T> FirstOrNone<T>(this IEnumerable<T> source, Func<T, bool> predicate)
Option<T> SingleOrNone<T>(this IEnumerable<T> source, Func<T, bool> predicate)Option<T> FirstOrNone<T>(this IEnumerable<Option<T>> options)
Option<T> FirstOrNone<T>(this IEnumerable<T> source, Func<T, bool> predicate)
Option<T> SingleOrNone<T>(this IEnumerable<T> source, Func<T, bool> predicate)These replace LINQ's FirstOrDefault and SingleOrDefault, which return null (or default) when nothing is found -- forcing you back into the null world:
// LINQ: returns null if not found (back to null-checking)
User? user = users.FirstOrDefault(u => u.IsActive);
// Option: returns None if not found (stays in Option world)
Option<User> user = users.FirstOrNone(u => u.IsActive);// LINQ: returns null if not found (back to null-checking)
User? user = users.FirstOrDefault(u => u.IsActive);
// Option: returns None if not found (stays in Option world)
Option<User> user = users.FirstOrNone(u => u.IsActive);FirstOrNone on IEnumerable<Option<T>> returns the first Some in the sequence, or None if all elements are None.
SingleOrNone behaves like SingleOrDefault but returns None instead of null when zero elements match. If multiple elements match, it throws InvalidOperationException -- the "single" semantics are preserved.
GetValueOrNone
Option<TValue> GetValueOrNone<TKey, TValue>(
this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)Option<TValue> GetValueOrNone<TKey, TValue>(
this IReadOnlyDictionary<TKey, TValue> dictionary, TKey key)Dictionary lookups are one of the most common sources of null in C# code. TryGetValue works but is awkward in pipelines:
// Traditional: awkward out parameter
if (cache.TryGetValue(key, out var value))
{
// use value
}
// Option: composable
Option<CachedItem> item = cache.GetValueOrNone(key);
string result = cache.GetValueOrNone(key)
.Filter(item => !item.IsExpired)
.Map(item => item.Data)
.OrDefault("cache miss");// Traditional: awkward out parameter
if (cache.TryGetValue(key, out var value))
{
// use value
}
// Option: composable
Option<CachedItem> item = cache.GetValueOrNone(key);
string result = cache.GetValueOrNone(key)
.Filter(item => !item.IsExpired)
.Map(item => item.Data)
.OrDefault("cache miss");GetValueOrNone wraps TryGetValue and returns Some(value) if the key exists, or None if it does not. The result flows naturally into Map/Bind/Filter chains.
Sequence: All-or-Nothing Collection Validation
Option<IReadOnlyList<T>> Sequence<T>(this IEnumerable<Option<T>> options)Option<IReadOnlyList<T>> Sequence<T>(this IEnumerable<Option<T>> options)Sequence converts a collection of Options into an Option of a collection. If every element is Some, the result is Some(list). If any element is None, the result is None.
This is an all-or-nothing operation. If you have a list of configuration values and all of them must be present for the configuration to be valid, Sequence tells you in a single call:
var configValues = new[]
{
config.GetValueOrNone("DatabaseHost"),
config.GetValueOrNone("DatabasePort"),
config.GetValueOrNone("DatabaseName"),
config.GetValueOrNone("DatabaseUser")
};
Option<IReadOnlyList<string>> allValues = configValues.Sequence();
allValues.Switch(
onSome: values =>
{
var connectionString = $"Host={values[0]};Port={values[1]};Database={values[2]};User={values[3]}";
_db.Connect(connectionString);
},
onNone: () => _logger.LogError("Missing required database configuration values")
);var configValues = new[]
{
config.GetValueOrNone("DatabaseHost"),
config.GetValueOrNone("DatabasePort"),
config.GetValueOrNone("DatabaseName"),
config.GetValueOrNone("DatabaseUser")
};
Option<IReadOnlyList<string>> allValues = configValues.Sequence();
allValues.Switch(
onSome: values =>
{
var connectionString = $"Host={values[0]};Port={values[1]};Database={values[2]};User={values[3]}";
_db.Connect(connectionString);
},
onNone: () => _logger.LogError("Missing required database configuration values")
);Sequence short-circuits on the first None -- it does not evaluate the rest of the sequence. This is important for performance when the sequence is large or when evaluating elements has side effects.
Traverse: Map Then Sequence
Option<IReadOnlyList<TOut>> Traverse<T, TOut>(
this IEnumerable<T> source, Func<T, Option<TOut>> mapper)Option<IReadOnlyList<TOut>> Traverse<T, TOut>(
this IEnumerable<T> source, Func<T, Option<TOut>> mapper)Traverse combines Map and Sequence into a single operation. It applies an Option-returning function to each element and collects the results. If all results are Some, you get Some(list). If any result is None, you get None.
var userIds = new[] { 1, 2, 3, 4, 5 };
// Traverse: look up each user, succeed only if ALL are found
Option<IReadOnlyList<User>> allUsers = userIds.Traverse(id => FindUser(id));
allUsers.Switch(
onSome: users => SendBulkEmail(users),
onNone: () => _logger.LogWarning("Cannot send bulk email: some users not found")
);var userIds = new[] { 1, 2, 3, 4, 5 };
// Traverse: look up each user, succeed only if ALL are found
Option<IReadOnlyList<User>> allUsers = userIds.Traverse(id => FindUser(id));
allUsers.Switch(
onSome: users => SendBulkEmail(users),
onNone: () => _logger.LogWarning("Cannot send bulk email: some users not found")
);Traverse is equivalent to .Select(mapper).Sequence(), but more efficient because it does not create the intermediate IEnumerable<Option<TOut>> -- it short-circuits on the first None.
This is a foundational operation in functional programming. In Haskell it is traverse / mapM. In Scala it is sequence on the mapped list. In FrenchExDev.Net.Options it is a single extension method call.
Use Values when you want the successes and can tolerate partial results. Use Sequence/Traverse when you need all-or-nothing semantics.
Result Integration
Option<T> and Result<T> solve related but distinct problems. Option<T> represents presence or absence: "Is there a value?" Result<T> represents success or failure: "Did the operation succeed, and if not, why?" The boundary between them is the question: "Do I need to know the reason for absence?"
FrenchExDev.Net.Options provides bidirectional conversion between the two types through OptionResultExtensions.
Option to Result: Adding Error Context
When you have an Option<T> and need to communicate why the value is absent, convert to Result<T>:
// Simple: provide an error message string
Result<User> result = FindUser(42).ToResult("User not found");
// Some(user) → Success(user)
// None → Failure("User not found")
// Factory: provide a ValidationResult for structured errors
Result<User> result = FindUser(42).ToResult(
() => new ValidationResult("User with ID 42 does not exist", new[] { "UserId" })
);
// Typed error: provide a custom error type
Result<User, UserError> result = FindUser(42).ToResult(
new UserError(UserErrorCode.NotFound, userId: 42)
);
// Lazy typed error: factory only called if None
Result<User, UserError> result = FindUser(42).ToResult(
() => new UserError(UserErrorCode.NotFound, userId: 42)
);// Simple: provide an error message string
Result<User> result = FindUser(42).ToResult("User not found");
// Some(user) → Success(user)
// None → Failure("User not found")
// Factory: provide a ValidationResult for structured errors
Result<User> result = FindUser(42).ToResult(
() => new ValidationResult("User with ID 42 does not exist", new[] { "UserId" })
);
// Typed error: provide a custom error type
Result<User, UserError> result = FindUser(42).ToResult(
new UserError(UserErrorCode.NotFound, userId: 42)
);
// Lazy typed error: factory only called if None
Result<User, UserError> result = FindUser(42).ToResult(
() => new UserError(UserErrorCode.NotFound, userId: 42)
);ToResult is the bridge you use at the boundary between "looking for something" (Option) and "reporting what went wrong" (Result). Typically, service methods return Option<T> for lookups, and API controllers convert to Result<T> to produce error responses:
public async Task<IActionResult> GetUser(int id)
{
return await _userService.FindUserAsync(id)
.ToResult($"User {id} not found")
.Match(
onSuccess: user => Ok(user),
onFailure: errors => NotFound(errors.First().ErrorMessage)
);
}public async Task<IActionResult> GetUser(int id)
{
return await _userService.FindUserAsync(id)
.ToResult($"User {id} not found")
.Match(
onSuccess: user => Ok(user),
onFailure: errors => NotFound(errors.First().ErrorMessage)
);
}Result to Option: Discarding Error Context
When you have a Result<T> but only care whether a value exists, not why it failed, convert to Option<T>:
// Result<T> to Option<T>
Option<User> user = CreateUser(command).ToOption();
// Success(user) → Some(user)
// Failure(errors) → None (errors discarded)
// Result<T, TError> to Option<T>
Option<User> user = CreateUser(command).ToOption();
// Success(user) → Some(user)
// Failure(error) → None (error discarded)// Result<T> to Option<T>
Option<User> user = CreateUser(command).ToOption();
// Success(user) → Some(user)
// Failure(errors) → None (errors discarded)
// Result<T, TError> to Option<T>
Option<User> user = CreateUser(command).ToOption();
// Success(user) → Some(user)
// Failure(error) → None (error discarded)ToOption discards the error information. Use it when you are downstream of the error-handling boundary and just need to know if a value is available.
Combined Operations
Two additional methods support common patterns where Option and Result interact:
// OrResult: if Option is None, try a Result-returning fallback
Result<User> user = FindCachedUser(42).OrResult(
() => _userService.CreateUser(new CreateUserCommand(42))
);
// Some(user) → Success(user)
// None → delegates to CreateUser, which returns Result<User>// OrResult: if Option is None, try a Result-returning fallback
Result<User> user = FindCachedUser(42).OrResult(
() => _userService.CreateUser(new CreateUserCommand(42))
);
// Some(user) → Success(user)
// None → delegates to CreateUser, which returns Result<User>OrResult is the pattern for "try the fast path (Option), fall back to the full path (Result)." The cache returns Option<User> because a cache miss is not an error. The service returns Result<User> because creation can fail with validation errors. OrResult bridges the two.
// BindResult: transform Option value using a Result-returning function
Option<Order> order = FindUser(42).BindResult(
user => _orderService.PlaceOrder(user, cart)
);
// Some(user) → calls PlaceOrder, Success(order) → Some(order), Failure → None
// None → None (PlaceOrder not called)// BindResult: transform Option value using a Result-returning function
Option<Order> order = FindUser(42).BindResult(
user => _orderService.PlaceOrder(user, cart)
);
// Some(user) → calls PlaceOrder, Success(order) → Some(order), Failure → None
// None → None (PlaceOrder not called)BindResult is useful when a pipeline starts in the Option world (lookup) and passes through the Result world (operation that can fail), but the caller only needs Option semantics. The Result errors are discarded if the operation fails.
Conversion Diagram
The diagram shows the four-way relationship. Some maps to Success and back. None maps to Failure (with an error message attached) and Failure maps back to None (with the error discarded). The conversions are lossless in the success direction and lossy in the failure direction -- by design.
Real-World Example: User Profile Lookup
Let us put everything together in a realistic scenario. A web application needs to render a user profile page. The logic involves:
- Look up the user by ID (might not exist)
- Check that the user's account is active
- Look up the user's avatar from a CDN (might not exist)
- Look up the user's subscription tier from a billing service (async, might fail)
- Build a profile view model
- Log the access for analytics
- Convert to a Result for the API response
Here is the complete implementation using Option pipelines:
public class UserProfileService
{
private readonly IUserRepository _users;
private readonly IAvatarService _avatars;
private readonly IBillingService _billing;
private readonly ILogger<UserProfileService> _logger;
public UserProfileService(
IUserRepository users,
IAvatarService avatars,
IBillingService billing,
ILogger<UserProfileService> logger)
{
_users = users;
_avatars = avatars;
_billing = billing;
_logger = logger;
}
public async Task<Result<ProfileViewModel>> GetProfileAsync(int userId)
{
return await _users.FindByIdAsync(userId) // Task<Option<User>>
.TapAsync(u =>
_logger.LogInformation("Profile requested for {Id}", u.Id))
.WhereAsync(u => u.IsActive) // filter inactive
.TapNoneAsync(() =>
_logger.LogWarning("User {Id} not found or inactive", userId))
.BindAsync(async user =>
{
// Avatar is optional -- missing avatar is not a failure
Option<string> avatarUrl = await _avatars.GetAvatarUrlAsync(user.Id);
// Subscription tier: use OrDefault for graceful degradation
string tier = await _billing.GetTierAsync(user.Id)
.MapAsync(t => t.Name)
.OrDefaultAsync("Free");
// Build the view model using Zip to combine user + optional avatar
return user.Zip(avatarUrl, (u, a) => new ProfileViewModel
{
Name = u.Name,
Email = u.Email,
AvatarUrl = a,
SubscriptionTier = tier,
MemberSince = u.CreatedAt
})
.Or(Option.Some(new ProfileViewModel
{
Name = user.Value.Name,
Email = user.Value.Email,
AvatarUrl = "/images/default-avatar.png",
SubscriptionTier = tier,
MemberSince = user.Value.CreatedAt
}));
})
.MatchAsync(
onSome: vm => Result<ProfileViewModel>.Success(vm),
onNone: () => Result<ProfileViewModel>.Failure(
new ValidationResult($"User {userId} not found or inactive"))
);
}
}public class UserProfileService
{
private readonly IUserRepository _users;
private readonly IAvatarService _avatars;
private readonly IBillingService _billing;
private readonly ILogger<UserProfileService> _logger;
public UserProfileService(
IUserRepository users,
IAvatarService avatars,
IBillingService billing,
ILogger<UserProfileService> logger)
{
_users = users;
_avatars = avatars;
_billing = billing;
_logger = logger;
}
public async Task<Result<ProfileViewModel>> GetProfileAsync(int userId)
{
return await _users.FindByIdAsync(userId) // Task<Option<User>>
.TapAsync(u =>
_logger.LogInformation("Profile requested for {Id}", u.Id))
.WhereAsync(u => u.IsActive) // filter inactive
.TapNoneAsync(() =>
_logger.LogWarning("User {Id} not found or inactive", userId))
.BindAsync(async user =>
{
// Avatar is optional -- missing avatar is not a failure
Option<string> avatarUrl = await _avatars.GetAvatarUrlAsync(user.Id);
// Subscription tier: use OrDefault for graceful degradation
string tier = await _billing.GetTierAsync(user.Id)
.MapAsync(t => t.Name)
.OrDefaultAsync("Free");
// Build the view model using Zip to combine user + optional avatar
return user.Zip(avatarUrl, (u, a) => new ProfileViewModel
{
Name = u.Name,
Email = u.Email,
AvatarUrl = a,
SubscriptionTier = tier,
MemberSince = u.CreatedAt
})
.Or(Option.Some(new ProfileViewModel
{
Name = user.Value.Name,
Email = user.Value.Email,
AvatarUrl = "/images/default-avatar.png",
SubscriptionTier = tier,
MemberSince = user.Value.CreatedAt
}));
})
.MatchAsync(
onSome: vm => Result<ProfileViewModel>.Success(vm),
onNone: () => Result<ProfileViewModel>.Failure(
new ValidationResult($"User {userId} not found or inactive"))
);
}
}This method is 40+ lines, but every line does one thing. The pipeline reads like a specification: find the user, check they are active, get their avatar, get their tier, build the model, convert to Result. The absence handling is woven into the pipeline structure itself -- there are no null checks, no if statements, no early returns.
Compare this to the null-based equivalent, which would require at least three null checks, two try-catch blocks, and a mutable ProfileViewModel that gets built up incrementally. The Option version is not shorter, but it is flatter -- a single chain instead of a tree of branches.
Testing with OptionAssertions
FrenchExDev.Net.Options.Testing provides four assertion methods designed for clean, expressive test code. They are framework-agnostic -- they work with xUnit, NUnit, MSTest, or any other test runner because they throw standard InvalidOperationException on failure.
ShouldBeSome: Assert Some and Extract
T ShouldBeSome<T>(this Option<T> option)T ShouldBeSome<T>(this Option<T> option)Asserts that the Option is Some and returns the unwrapped value. Throws if None.
[Fact]
public void FindUser_WhenUserExists_ReturnsSome()
{
// Arrange
var repo = new InMemoryUserRepository();
repo.Add(new User(42, "Alice"));
// Act
Option<User> result = repo.FindById(42);
// Assert
User user = result.ShouldBeSome();
Assert.Equal("Alice", user.Name);
Assert.Equal(42, user.Id);
}[Fact]
public void FindUser_WhenUserExists_ReturnsSome()
{
// Arrange
var repo = new InMemoryUserRepository();
repo.Add(new User(42, "Alice"));
// Act
Option<User> result = repo.FindById(42);
// Assert
User user = result.ShouldBeSome();
Assert.Equal("Alice", user.Name);
Assert.Equal(42, user.Id);
}ShouldBeSome returns the value, so you can chain further assertions on it. This eliminates the awkward pattern of asserting IsSome and then accessing .Value separately.
ShouldBeSome with Expected Value
void ShouldBeSome<T>(this Option<T> option, T expected)void ShouldBeSome<T>(this Option<T> option, T expected)Asserts that the Option is Some(expected), using value equality:
[Fact]
public void Map_TransformsValue()
{
Option<int> result = Option.Some(5).Map(x => x * 2);
result.ShouldBeSome(10);
}
[Fact]
public void OrDefault_WhenSome_ReturnsOriginalValue()
{
string result = Option.Some("hello").OrDefault("fallback");
Assert.Equal("hello", result);
}[Fact]
public void Map_TransformsValue()
{
Option<int> result = Option.Some(5).Map(x => x * 2);
result.ShouldBeSome(10);
}
[Fact]
public void OrDefault_WhenSome_ReturnsOriginalValue()
{
string result = Option.Some("hello").OrDefault("fallback");
Assert.Equal("hello", result);
}ShouldBeNone: Assert None
void ShouldBeNone<T>(this Option<T> option)void ShouldBeNone<T>(this Option<T> option)Asserts that the Option is None. Throws if Some:
[Fact]
public void FindUser_WhenUserDoesNotExist_ReturnsNone()
{
var repo = new InMemoryUserRepository();
Option<User> result = repo.FindById(999);
result.ShouldBeNone();
}
[Fact]
public void Filter_WhenPredicateFails_ReturnsNone()
{
Option<int> result = Option.Some(5).Filter(x => x > 10);
result.ShouldBeNone();
}[Fact]
public void FindUser_WhenUserDoesNotExist_ReturnsNone()
{
var repo = new InMemoryUserRepository();
Option<User> result = repo.FindById(999);
result.ShouldBeNone();
}
[Fact]
public void Filter_WhenPredicateFails_ReturnsNone()
{
Option<int> result = Option.Some(5).Filter(x => x > 10);
result.ShouldBeNone();
}ShouldBeSomeAnd: Assert Some with Predicate
T ShouldBeSomeAnd<T>(this Option<T> option, Func<T, bool> predicate)T ShouldBeSomeAnd<T>(this Option<T> option, Func<T, bool> predicate)Asserts that the Option is Some and the value satisfies the predicate. Returns the value if both conditions hold:
[Fact]
public void FindUser_WhenUserExists_HasValidEmail()
{
var repo = new InMemoryUserRepository();
repo.Add(new User(42, "Alice", "alice@example.com"));
Option<User> result = repo.FindById(42);
User user = result.ShouldBeSomeAnd(u => u.Email.Contains("@"));
Assert.Equal("alice@example.com", user.Email);
}[Fact]
public void FindUser_WhenUserExists_HasValidEmail()
{
var repo = new InMemoryUserRepository();
repo.Add(new User(42, "Alice", "alice@example.com"));
Option<User> result = repo.FindById(42);
User user = result.ShouldBeSomeAnd(u => u.Email.Contains("@"));
Assert.Equal("alice@example.com", user.Email);
}ShouldBeSomeAnd combines two assertions into one: "Is it Some?" and "Does the value satisfy this condition?" This is cleaner than extracting the value and then asserting separately:
// Without ShouldBeSomeAnd
var user = result.ShouldBeSome();
Assert.True(user.Email.Contains("@"));
// With ShouldBeSomeAnd
var user = result.ShouldBeSomeAnd(u => u.Email.Contains("@"));// Without ShouldBeSomeAnd
var user = result.ShouldBeSome();
Assert.True(user.Email.Contains("@"));
// With ShouldBeSomeAnd
var user = result.ShouldBeSomeAnd(u => u.Email.Contains("@"));Full Test Suite Example
Here is a complete test class covering the major Option operations:
public class OptionPipelineTests
{
private readonly InMemoryUserRepository _users = new();
private readonly InMemoryAddressRepository _addresses = new();
public OptionPipelineTests()
{
_users.Add(new User(1, "Alice", isActive: true, addressId: 100));
_users.Add(new User(2, "Bob", isActive: false, addressId: 200));
_addresses.Add(new Address(100, "123 Main St", "Paris"));
// Address 200 intentionally missing
}
[Fact]
public void Pipeline_ActiveUserWithAddress_ReturnsCityName()
{
Option<string> city = _users.FindById(1)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeSome("Paris");
}
[Fact]
public void Pipeline_InactiveUser_ReturnsNone()
{
Option<string> city = _users.FindById(2)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeNone();
}
[Fact]
public void Pipeline_NonexistentUser_ReturnsNone()
{
Option<string> city = _users.FindById(999)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeNone();
}
[Fact]
public void Pipeline_UserWithMissingAddress_ReturnsNone()
{
_users.Add(new User(3, "Charlie", isActive: true, addressId: 999));
Option<string> city = _users.FindById(3)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeNone();
}
[Fact]
public void Zip_BothPresent_ReturnsCombined()
{
var user = _users.FindById(1);
var address = _addresses.FindById(100);
Option<string> label = user.Zip(address, (u, a) => $"{u.Name} at {a.City}");
label.ShouldBeSome("Alice at Paris");
}
[Fact]
public void Zip_OneMissing_ReturnsNone()
{
var user = _users.FindById(1);
var address = _addresses.FindById(999);
Option<string> label = user.Zip(address, (u, a) => $"{u.Name} at {a.City}");
label.ShouldBeNone();
}
[Fact]
public void Sequence_AllPresent_ReturnsSomeList()
{
var options = new[]
{
Option.Some(1),
Option.Some(2),
Option.Some(3)
};
Option<IReadOnlyList<int>> result = options.Sequence();
var list = result.ShouldBeSome();
Assert.Equal(3, list.Count);
Assert.Equal(new[] { 1, 2, 3 }, list);
}
[Fact]
public void Sequence_OneNone_ReturnsNone()
{
var options = new[]
{
Option.Some(1),
Option.None<int>(),
Option.Some(3)
};
Option<IReadOnlyList<int>> result = options.Sequence();
result.ShouldBeNone();
}
[Fact]
public void ToResult_WhenSome_ReturnsSuccess()
{
Result<User> result = _users.FindById(1).ToResult("User not found");
Assert.True(result.IsSuccess);
}
[Fact]
public void ToResult_WhenNone_ReturnsFailure()
{
Result<User> result = _users.FindById(999).ToResult("User not found");
Assert.True(result.IsFailure);
}
[Fact]
public void LinqQuery_MultipleBinds_ChainsCorrectly()
{
Option<string> result =
from user in _users.FindById(1)
where user.IsActive
from address in _addresses.FindById(user.AddressId)
select $"{user.Name}: {address.City}";
result.ShouldBeSome("Alice: Paris");
}
}public class OptionPipelineTests
{
private readonly InMemoryUserRepository _users = new();
private readonly InMemoryAddressRepository _addresses = new();
public OptionPipelineTests()
{
_users.Add(new User(1, "Alice", isActive: true, addressId: 100));
_users.Add(new User(2, "Bob", isActive: false, addressId: 200));
_addresses.Add(new Address(100, "123 Main St", "Paris"));
// Address 200 intentionally missing
}
[Fact]
public void Pipeline_ActiveUserWithAddress_ReturnsCityName()
{
Option<string> city = _users.FindById(1)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeSome("Paris");
}
[Fact]
public void Pipeline_InactiveUser_ReturnsNone()
{
Option<string> city = _users.FindById(2)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeNone();
}
[Fact]
public void Pipeline_NonexistentUser_ReturnsNone()
{
Option<string> city = _users.FindById(999)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeNone();
}
[Fact]
public void Pipeline_UserWithMissingAddress_ReturnsNone()
{
_users.Add(new User(3, "Charlie", isActive: true, addressId: 999));
Option<string> city = _users.FindById(3)
.Filter(u => u.IsActive)
.Bind(u => _addresses.FindById(u.AddressId))
.Map(a => a.City);
city.ShouldBeNone();
}
[Fact]
public void Zip_BothPresent_ReturnsCombined()
{
var user = _users.FindById(1);
var address = _addresses.FindById(100);
Option<string> label = user.Zip(address, (u, a) => $"{u.Name} at {a.City}");
label.ShouldBeSome("Alice at Paris");
}
[Fact]
public void Zip_OneMissing_ReturnsNone()
{
var user = _users.FindById(1);
var address = _addresses.FindById(999);
Option<string> label = user.Zip(address, (u, a) => $"{u.Name} at {a.City}");
label.ShouldBeNone();
}
[Fact]
public void Sequence_AllPresent_ReturnsSomeList()
{
var options = new[]
{
Option.Some(1),
Option.Some(2),
Option.Some(3)
};
Option<IReadOnlyList<int>> result = options.Sequence();
var list = result.ShouldBeSome();
Assert.Equal(3, list.Count);
Assert.Equal(new[] { 1, 2, 3 }, list);
}
[Fact]
public void Sequence_OneNone_ReturnsNone()
{
var options = new[]
{
Option.Some(1),
Option.None<int>(),
Option.Some(3)
};
Option<IReadOnlyList<int>> result = options.Sequence();
result.ShouldBeNone();
}
[Fact]
public void ToResult_WhenSome_ReturnsSuccess()
{
Result<User> result = _users.FindById(1).ToResult("User not found");
Assert.True(result.IsSuccess);
}
[Fact]
public void ToResult_WhenNone_ReturnsFailure()
{
Result<User> result = _users.FindById(999).ToResult("User not found");
Assert.True(result.IsFailure);
}
[Fact]
public void LinqQuery_MultipleBinds_ChainsCorrectly()
{
Option<string> result =
from user in _users.FindById(1)
where user.IsActive
from address in _addresses.FindById(user.AddressId)
select $"{user.Name}: {address.City}";
result.ShouldBeSome("Alice: Paris");
}
}Notice how every test follows the same structure: arrange, act (build the pipeline), assert with ShouldBeSome or ShouldBeNone. There are no null checks in any test. There are no Assert.NotNull calls. The Option assertions are self-documenting: ShouldBeSome("Paris") tells you exactly what the expected outcome is.
DI Registration
FrenchExDev.Net.Options itself does not require DI registration -- Option<T> is a value type (well, a record) that you create inline. There is no AddOptions() call and no service to register.
However, services that return Options integrate naturally with the [Injectable] source generator from FrenchExDev.Net.Injectable. The pattern is:
[Injectable(ServiceLifetime.Scoped)]
public class UserRepository : IUserRepository
{
private readonly AppDbContext _db;
public UserRepository(AppDbContext db) => _db = db;
public Option<User> FindById(int id)
{
var entity = _db.Users.FirstOrDefault(u => u.Id == id);
return Option.From(entity);
}
public async Task<Option<User>> FindByIdAsync(int id)
{
var entity = await _db.Users.FirstOrDefaultAsync(u => u.Id == id);
return Option.From(entity);
}
public async Task<Option<User>> FindByEmailAsync(string email)
{
var entity = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
return Option.From(entity);
}
}[Injectable(ServiceLifetime.Scoped)]
public class UserRepository : IUserRepository
{
private readonly AppDbContext _db;
public UserRepository(AppDbContext db) => _db = db;
public Option<User> FindById(int id)
{
var entity = _db.Users.FirstOrDefault(u => u.Id == id);
return Option.From(entity);
}
public async Task<Option<User>> FindByIdAsync(int id)
{
var entity = await _db.Users.FirstOrDefaultAsync(u => u.Id == id);
return Option.From(entity);
}
public async Task<Option<User>> FindByEmailAsync(string email)
{
var entity = await _db.Users.FirstOrDefaultAsync(u => u.Email == email);
return Option.From(entity);
}
}The [Injectable] attribute causes the source generator to emit a AddUserRepository extension method on IServiceCollection. The repository itself uses Option.From to bridge between Entity Framework's nullable returns and the Option world.
This pattern has a compounding benefit: every consumer of IUserRepository receives Option<User> instead of User?. This means the null-free guarantee propagates outward from the repository through every service, handler, and controller that depends on it. One Option.From at the data boundary eliminates null checks across the entire call chain.
[Injectable(ServiceLifetime.Scoped)]
public class OrderService : IOrderService
{
private readonly IUserRepository _users;
private readonly IProductRepository _products;
public OrderService(IUserRepository users, IProductRepository products)
{
_users = users;
_products = products;
}
public async Task<Option<OrderConfirmation>> PlaceOrderAsync(
int userId, int productId, int quantity)
{
// Everything here is Option-based -- no null checks needed
return await
from user in _users.FindByIdAsync(userId)
where user.IsActive
from product in _products.FindByIdAsync(productId)
where product.Stock >= quantity
select new OrderConfirmation(user.Name, product.Name, quantity);
}
}[Injectable(ServiceLifetime.Scoped)]
public class OrderService : IOrderService
{
private readonly IUserRepository _users;
private readonly IProductRepository _products;
public OrderService(IUserRepository users, IProductRepository products)
{
_users = users;
_products = products;
}
public async Task<Option<OrderConfirmation>> PlaceOrderAsync(
int userId, int productId, int quantity)
{
// Everything here is Option-based -- no null checks needed
return await
from user in _users.FindByIdAsync(userId)
where user.IsActive
from product in _products.FindByIdAsync(productId)
where product.Stock >= quantity
select new OrderConfirmation(user.Name, product.Name, quantity);
}
}The PlaceOrderAsync method uses LINQ query syntax across two async Option-returning repository calls. If either lookup returns None, or if any where filter fails, the entire method returns None. No null checks, no exceptions, no conditional branching. The pipeline structure is the control flow.
Nullable Conversion: ToNullable and ToNullableStruct
When you need to interoperate with APIs that expect nullable types -- serialization libraries, third-party code, database parameters -- Option<T> provides escape hatches:
// Reference types: ToNullable
Option<string> name = Option.Some("Alice");
string? nullable = name.ToNullable(); // "Alice"
Option<string> noName = Option.None<string>();
string? nullable2 = noName.ToNullable(); // null
// Value types: ToNullableStruct
Option<int> age = Option.Some(30);
int? nullableAge = age.ToNullableStruct(); // 30
Option<int> noAge = Option.None<int>();
int? nullableAge2 = noAge.ToNullableStruct(); // null// Reference types: ToNullable
Option<string> name = Option.Some("Alice");
string? nullable = name.ToNullable(); // "Alice"
Option<string> noName = Option.None<string>();
string? nullable2 = noName.ToNullable(); // null
// Value types: ToNullableStruct
Option<int> age = Option.Some(30);
int? nullableAge = age.ToNullableStruct(); // 30
Option<int> noAge = Option.None<int>();
int? nullableAge2 = noAge.ToNullableStruct(); // nullTwo separate methods exist because C# has two separate nullable systems: T? for reference types (where T? is the same CLR type as T, just annotated) and Nullable<T> for value types (where int? is actually Nullable<int>, a different type). The Option API makes this distinction explicit rather than hiding it behind overload resolution.
Use ToNullable and ToNullableStruct at the boundary of your Option-based code -- when serializing to JSON, when passing parameters to SQL queries, when calling a third-party library that expects nullable types. Inside your Option pipelines, stay in the Option world.
Why Not Just Use Nullable Reference Types?
This is a fair question. C# 8+ nullable reference types (NRTs) address many of the same problems. Here is an honest comparison:
What NRTs do well:
- Zero runtime overhead (they are compiler annotations, not wrapper types)
- Work with existing code without modification
- Integrated into the language and IDE
- No library dependency
What NRTs cannot do:
- They are warnings, not errors -- they can be suppressed with
! - They have no runtime representation -- a
stringand astring?are the same type at runtime - They do not compose -- there is no
Map,Bind,Filteronstring? - They do not force exhaustive handling -- you can ignore the warning and access the value
- They interact poorly with generics --
T?behaves differently for reference types and value types - They have no
Sequence/Traversefor collection-level absence reasoning
Option<T> is a library-level solution that provides stronger guarantees at the cost of a wrapper type. NRTs are a language-level solution that provides weaker guarantees at the cost of nothing. They are not mutually exclusive -- you can (and should) enable NRTs in your project and use Option<T> for domain-level absence modeling. NRTs catch accidental nulls at the surface level; Option<T> provides compositional absence handling in your business logic.
The rule of thumb: use Option<T> when absence is a domain concept (user not found, configuration missing, lookup failed). Use NRTs when absence is an implementation detail (this local variable might be null temporarily).
Performance Considerations
A common concern with wrapper types is allocation overhead. Here are the facts:
Option<T> is a sealed record, which in C# means it is a reference type allocated on the heap. Each Some creates an object. Each Map/Bind/Filter in a pipeline creates a new Option<T> instance. This is comparable to LINQ's allocation profile: each .Where() and .Select() allocates an enumerator object.
In practice, this is rarely a bottleneck. The allocations are small (one object per pipeline step), short-lived (GC Gen 0), and the JIT is excellent at optimizing sealed types. Profile before optimizing.
If you are in a hot loop processing millions of Options per second, consider:
- Use
Match/Switchat the boundary and work with raw values inside the loop - Use
IsSome/Valuedirectly (accepting the weaker guarantee) for the critical path - Pool or cache results at a higher level to avoid repeated pipeline construction
For the vast majority of business logic -- service methods, API handlers, domain operations -- the allocation cost of Option<T> is negligible compared to the database queries, HTTP calls, and serialization that surround it.
Pitfall 1: Accessing .Value Without Checking
// BAD: throws InvalidOperationException if None
var name = option.Value;
// GOOD: use Match or OrDefault
var name = option.Match(
onSome: v => v,
onNone: () => "default"
);
// ALSO GOOD: use OrDefault
var name = option.OrDefault("default");// BAD: throws InvalidOperationException if None
var name = option.Value;
// GOOD: use Match or OrDefault
var name = option.Match(
onSome: v => v,
onNone: () => "default"
);
// ALSO GOOD: use OrDefault
var name = option.OrDefault("default");The .Value property exists for interop, but it should rarely appear in production code. If you find yourself writing .Value, ask whether Match, OrDefault, or Map would be more appropriate.
Pitfall 2: Nesting Options
// BAD: Option<Option<T>> is almost always a mistake
Option<Option<User>> nested = option.Map(id => FindUser(id));
// GOOD: use Bind when the function returns Option
Option<User> flat = option.Bind(id => FindUser(id));// BAD: Option<Option<T>> is almost always a mistake
Option<Option<User>> nested = option.Map(id => FindUser(id));
// GOOD: use Bind when the function returns Option
Option<User> flat = option.Bind(id => FindUser(id));If you see Option<Option<T>> in your code, you used Map where you should have used Bind. Map is for functions that return T. Bind is for functions that return Option<T>.
Pitfall 3: Ignoring the Match Result
// BAD: calling Match for side effects and ignoring the return value
option.Match(
onSome: v => { Console.WriteLine(v); return 0; },
onNone: () => { Console.WriteLine("none"); return 0; }
);
// GOOD: use Switch for side effects
option.Switch(
onSome: v => Console.WriteLine(v),
onNone: () => Console.WriteLine("none")
);// BAD: calling Match for side effects and ignoring the return value
option.Match(
onSome: v => { Console.WriteLine(v); return 0; },
onNone: () => { Console.WriteLine("none"); return 0; }
);
// GOOD: use Switch for side effects
option.Switch(
onSome: v => Console.WriteLine(v),
onNone: () => Console.WriteLine("none")
);Pitfall 4: Using Option for Error Handling
// BAD: None does not tell you WHY the operation failed
Option<Order> order = ProcessPayment(card, amount);
// If None: was the card declined? Was the amount invalid? Was the service down?
// GOOD: use Result<T> when you need error context
Result<Order> order = ProcessPayment(card, amount);
// Failure carries ValidationResult with specific error message// BAD: None does not tell you WHY the operation failed
Option<Order> order = ProcessPayment(card, amount);
// If None: was the card declined? Was the amount invalid? Was the service down?
// GOOD: use Result<T> when you need error context
Result<Order> order = ProcessPayment(card, amount);
// Failure carries ValidationResult with specific error messageOption is for absence. Result is for failure. If the caller needs to distinguish between different failure modes, use Result. If the caller only needs to know "is there a value or not," use Option.
Pitfall 5: Overusing OrDefault
// BAD: defaulting too early throws away information
string email = FindUser(42)
.Map(u => u.Email)
.OrDefault("unknown@example.com"); // now you have a string, not an Option
// ... and you cannot tell if this email is real or defaulted
// GOOD: stay in Option as long as possible
Option<string> email = FindUser(42)
.Map(u => u.Email);
// Only OrDefault at the final consumption point
string display = email.OrDefault("No email on file");// BAD: defaulting too early throws away information
string email = FindUser(42)
.Map(u => u.Email)
.OrDefault("unknown@example.com"); // now you have a string, not an Option
// ... and you cannot tell if this email is real or defaulted
// GOOD: stay in Option as long as possible
Option<string> email = FindUser(42)
.Map(u => u.Email);
// Only OrDefault at the final consumption point
string display = email.OrDefault("No email on file");The longer you stay in the Option world, the more the compiler can help you. OrDefault is an exit ramp -- use it at the very end of a pipeline, not in the middle.
Pitfall 6: Creating Option of a Collection Instead of a Collection of Options
// Confusing: Option<List<T>> -- is None "no list" or "empty list"?
Option<List<User>> users = FindActiveUsers();
// Clearer: return an empty list for "no results" and Option<T> for individual lookups
List<User> users = FindActiveUsers(); // empty list = no results
Option<User> user = FindUserById(42); // None = not found// Confusing: Option<List<T>> -- is None "no list" or "empty list"?
Option<List<User>> users = FindActiveUsers();
// Clearer: return an empty list for "no results" and Option<T> for individual lookups
List<User> users = FindActiveUsers(); // empty list = no results
Option<User> user = FindUserById(42); // None = not foundOption<T> is for individual values that might be absent, not for collections that might be empty. An empty collection already represents "no items" -- wrapping it in Option adds a redundant layer of absence. The exception is Sequence/Traverse, where Option<IReadOnlyList<T>> represents "all items are present" vs "at least one is missing" -- a semantically different question from "are there any items."
vs. System.Nullable<T>
Nullable<T> (i.e., int?, bool?) only works for value types. It has no Map, Bind, or Match. It has a HasValue property and a Value property that throws on access -- the same footgun as Option<T>.Value, but without the pipeline API that makes direct access unnecessary.
vs. F# Option
F# has Option<'T> built into the language as a discriminated union with Some and None cases. The FrenchExDev implementation mirrors the F# design but adapts it to C# idioms: extension methods instead of module functions, Match/Switch methods instead of pattern matching expressions (which are more limited in C# than in F#), and where T : notnull to compensate for C#'s lack of a null-free type system.
vs. LanguageExt
LanguageExt is the most popular functional programming library for C#. It provides Option<T> along with hundreds of other types (Either, Validation, Eff, Aff, Seq, etc.). It is a comprehensive functional programming toolkit.
FrenchExDev.Net.Options is deliberately smaller. It provides Option<T> and nothing else. If you want Either, use OneOf from FrenchExDev.Net.Union. If you want Validation, use Result<T> from FrenchExDev.Net.Result. Each concern is a separate package with a separate dependency. You do not pay for what you do not use.
The API surfaces are similar -- both have Map, Bind, Match, Filter. The main difference is scope: LanguageExt is a framework; FrenchExDev.Net.Options is a building block.
Summary
The Option pattern replaces null with a type-safe, composable alternative that the compiler enforces. Here is how the two approaches compare:
| Dimension | Null Approach | Option Approach |
|---|---|---|
| Type safety | T? is the same runtime type as T; null inhabits every reference type |
Option<T> is a distinct type; Some and None are the only states |
| Exhaustive handling | No enforcement; warnings are opt-in and suppressible | Match requires both branches at compile time |
| Composition | ?. chains produce T?; no Map, Bind, or Filter |
Full pipeline: Map, Bind, Filter, Tap, Zip, Sequence, Traverse |
| Async support | Manual if (x != null) inside await chains |
Task<Option<T>> extensions: chain without intermediate awaits |
| Collection handling | FirstOrDefault returns null; manual null filtering |
FirstOrNone, Values, Sequence, Traverse -- all Option-native |
| Error context | null means "absent" with no further information |
ToResult bridges to Result<T> when error context is needed |
| Testing | Assert.NotNull(x) then cast; Assert.Null(x) |
ShouldBeSome(), ShouldBeSome(expected), ShouldBeNone(), ShouldBeSomeAnd(predicate) |
The key insight is not that null is always wrong -- it is that null conflates three separate concerns: absence of a value, failure of an operation, and uninitialized state. Option<T> handles the first. Result<T> handles the second. And proper initialization handles the third. By giving each concern its own type, you give the compiler the information it needs to help you write correct code.
FrenchExDev.Net.Options is one package, one namespace, one core type, and a collection of extension methods that compose. It does not try to be a functional programming framework. It does not require you to rewrite your codebase. It gives you a sealed record with two states, exhaustive matching, and a pipeline API that eliminates null checks from the inside out.
Next in the series: Part III: The Union Pattern, where we extend the idea of closed type hierarchies from two states (Some/None) to N states (OneOf<T1, T2, T3, T4>) -- discriminated unions in C# without language support.