Maybe<T>
Maybe<T> models an optional value: it is either Some — a present value of type T — or None, the explicit absence of a value. It is a type-safe alternative to null that forces the absent case to be handled at compile time, and it composes through Map, Bind, Filter, and LINQ so you can describe a whole pipeline without scattering null checks.
Maybe<T> is a readonly record struct, so it allocates nothing on the heap for the wrapper itself. The constraint where T : notnull guarantees a Some never holds null.
When to reach for Maybe<T>
- A value may legitimately be absent — cache misses, lookups, optional configuration.
- A query may return no row —
FindByIdreturningMaybe<User>instead of a nullable. - You want to chain steps that each may produce nothing, without
if (x is null)at every hop.
Use Result<T> instead when absence is an error that needs a reason. Maybe<T> says "nothing here"; Result<T> says "here is why it failed". Bridge from one to the other with ToResult.
Creating a Maybe<T>
using UnambitiousFx.Functional;
// Static factories
Maybe<int> some = Maybe.Some(42);
Maybe<int> none = Maybe.None<int>();
// Instance factories
Maybe<int> also = Maybe<int>.Some(42);
// Implicit conversion from a (nullable) value: null becomes None
Maybe<string> fromValue = "hello"; // Some("hello")
Maybe<string> fromNull = null; // None
Inspecting state
Maybe<int> maybe = Maybe.Some(42);
bool present = maybe.IsSome; // true
bool absent = maybe.IsNone; // false
// Case yields the value when Some, or default(T) when None.
int? raw = maybe.Case; // 42
// Try-style extraction with a guaranteed-non-null out value.
if (maybe.Some(out var value))
{
Console.WriteLine(value); // 42
}
Prefer Match, ValueOr, or Some(out …) over reading Case directly — they make the None case explicit.
Matching and switching
Match collapses both branches into a single value; Switch (and the void Match overload) runs a side effect per branch.
string label = maybe.Match(
some: value => $"Found {value}",
none: () => "Nothing here");
maybe.Switch(
some: value => Console.WriteLine($"Got {value}"),
none: () => Console.WriteLine("Empty"));
// Run an action only on one branch:
maybe.IfSome(value => Console.WriteLine(value));
maybe.IfNone(() => Console.WriteLine("Empty"));
Transforming: Map, Bind, Filter
Maptransforms the inner value, preserving None.Bindtransforms the value with a function that itself returns aMaybe<T>, flattening the result (noMaybe<Maybe<T>>).Filterkeeps the value only when a predicate holds; otherwise it becomes None.
Maybe<string> name = Maybe.Some(new User("Ada"))
.Filter(user => user.IsActive) // Some(user) or None
.Map(user => user.Name) // Some("Ada") or None
.Bind(LookupDisplayName); // flattens Maybe<string>
Maybe<string> LookupDisplayName(string name) =>
name.Length > 0 ? Maybe.Some(name.ToUpperInvariant()) : Maybe.None<string>();
Extracting a value: ValueOr and OrElse
ValueOr unwraps to a plain T, supplying a fallback for None. OrElse stays in Maybe<T> space, supplying an alternative Maybe<T>.
int retries = Maybe.None<int>().ValueOr(3); // 3
int lazy = Maybe.None<int>().ValueOr(() => Compute()); // factory only runs on None
Maybe<User> user = primaryLookup
.OrElse(secondaryLookup) // try another Maybe
.OrElse(() => FallbackLookup()); // or a lazily-produced one
To read the raw inner value or default(T), use the Case property (there is no separate ValueOrDefault on Maybe<T>).
Bridging to Result
When an empty Maybe<T> should become a failure, convert it with ToResult, supplying the failure to use for None. There are overloads for a ready-made Failure, a lazy Func<Failure>, and a plain message string.
using UnambitiousFx.Functional.Failures;
Result<User> required = FindUser(id)
.ToResult(new NotFoundFailure("User", id.ToString()));
Result<User> lazyFailure = FindUser(id)
.ToResult(() => new NotFoundFailure("User", id.ToString()));
Result<User> messageFailure = FindUser(id)
.ToResult("User not found");
See Failures and Metadata for the failure catalog.
Side effects: the Tap family
Tap operators run a side effect and return the original Maybe<T> unchanged, so they slot into a pipeline without breaking the chain.
Tap/TapSomerun only when Some.TapNoneruns only when None.
Maybe<Order> order = LookupOrder(id)
.TapSome(o => _logger.LogInformation("Found order {Id}", o.Id))
.TapNone(() => _logger.LogWarning("Order {Id} missing", id));
LINQ query syntax
Maybe<T> implements Select, SelectMany, and Where, so query syntax works directly. A None anywhere short-circuits the whole query to None.
Maybe<PublicProfile> profile =
from user in GetUser(id)
from settings in GetSettings(user.SettingsId)
where settings.IsPublic
select new PublicProfile(user.Name, settings);
Select maps, SelectMany binds (with an optional projector), and where filters.
Async pipelines
Every major operator has a ValueTask<Maybe<T>> extension, so you can await a chain without unwrapping at each step. Both synchronous and asynchronous delegate overloads are available (e.g. Map(Func<T, TOut>) and Map(Func<T, ValueTask<TOut>>)).
ValueTask<Maybe<User>> userTask = GetUserAsync(id);
Maybe<string> name = await userTask
.Filter(u => u.IsActive)
.Map(u => u.Name);
Result<Profile> profile = await userTask
.Bind(u => GetProfileAsync(u.ProfileId)) // Func<T, ValueTask<Maybe<Profile>>>
.ToResult(() => new NotFoundFailure("Profile", id.ToString()));
The async surface covers Map, Bind, Filter, OrElse, ValueOr, Match, Switch, TapSome, TapNone, and ToResult (including a Func<ValueTask<Failure>> overload).
Best practices
- Prefer
Maybe<T>over nullable when the value flows through a chain — the operators keep the None case from leaking into every call site. - Reserve
Maybe<T>for genuine optionality. If the caller needs to know why a value is missing, useResult<T>. - Don't nest — reach for
Bindinstead ofMapwhenever your function returns anotherMaybe<T>. - Avoid reading
Casedirectly in business logic; preferMatch,ValueOr, orSome(out …). - Use
ValueOrfor simple defaults andOrElsewhen the fallback is itself optional.
Testing with Functional.xunit
Functional.xunit provides fluent assertions for Maybe<T> via ShouldBe().
using UnambitiousFx.Functional;
[Fact]
public void Map_WithSome_TransformsValue()
{
// Arrange (Given)
var maybe = Maybe.Some(5);
// Act (When)
var mapped = maybe.Map(x => x * 2);
// Assert (Then)
mapped.ShouldBe()
.Some()
.And(value => Assert.Equal(10, value));
}
[Fact]
public void Filter_WhenPredicateFails_BecomesNone()
{
// Arrange (Given)
var maybe = Maybe.Some(3);
// Act (When)
var filtered = maybe.Filter(x => x > 10);
// Assert (Then)
filtered.ShouldBe().None();
}
See also
- Maybe API Reference — the full method surface, grouped by category.
- Result — for operations that can fail with a reason.
- Failures and Metadata — typed failures and contextual metadata.