Why Maybe?
C# already gives you T?, Nullable<T>, and plain old null. So why does Trellis add Maybe<T>?
Because sometimes you do not just need “a value that might be missing.” You need that optional value to be:
- explicit in your domain model
- composable in a pipeline
- easy to convert into
Result<T>when absence becomes an error
That is the job of Maybe<T>.
Tip
Maybe<T> is not a replacement for every nullable value in your application. It shines when optionality is part of the domain and needs to compose with Trellis result flows.
Start Here: the Smallest Useful Example
using Trellis;
Maybe<string> middleName = Maybe.From("Byron");
Maybe<string> noNickname = Maybe<string>.None;
Console.WriteLine(middleName.HasValue); // True
Console.WriteLine(noNickname.HasValue); // False
var displayName = middleName.Match(
some => some,
() => "(none)");
The Problem Maybe<T> Solves
The issue with plain nullable values is not that they are bad. The issue is that they stop being expressive once the code becomes more domain-driven.
Consider an entity with an optional phone number:
using Trellis;
public sealed record PhoneNumber(string Value);
public sealed class Customer
{
public Maybe<PhoneNumber> Phone { get; }
public Customer(Maybe<PhoneNumber> phone) => Phone = phone;
}
That says something precise:
- the customer may or may not have a phone number
- if there is a phone number, it is a real
PhoneNumber - “empty phone number” is not a separate fake concept inside the value object
That is usually clearer than pushing optionality down into the value object itself.
When Maybe<T> Is Better Than T?
Use Maybe<T> when...
| Scenario | Why Maybe<T> helps |
|---|---|
| Optional value objects | It keeps the value object valid and moves optionality to the containing model |
| Optional data in a pipeline | It composes with Map, Bind, Where, Tap, and ToResult(...) |
| Absence should become a domain error later | ToResult(...) makes that conversion explicit |
| You want equality and operators for optional values | Maybe<T> supports Equals, ==, and != |
Use T? when...
| Scenario | Better choice |
|---|---|
| Optional primitives on DTOs | int?, DateTime?, decimal? |
| Optional strings or references with no pipeline needs | string?, User? |
| Interop with APIs that already use nullable reference types | T? |
| Performance-sensitive code where nullable semantics are enough | T? / Nullable<T> |
Note
A practical rule: use Maybe<T> for optional domain values that need Trellis composition. Use T? for ordinary nullable data.
Creating Maybe<T> Values
Some value
using Trellis;
Maybe<string> some = Maybe.From("Ada");
Maybe<string> alsoSome = Maybe<string>.From("Ada");
Maybe<string> implicitSome = "Ada";
No value
using Trellis;
Maybe<string> none = Maybe<string>.None;
Maybe<int> missingCount = Maybe<int>.None;
Null becomes None
using Trellis;
string? input = null;
Maybe<string> maybeName = Maybe.From(input);
Console.WriteLine(maybeName.HasValue); // False
Reading Values Safely
The problem with optional values is not creating them. It is consuming them without littering your code with if statements.
Match
using Trellis;
Maybe<string> nickname = Maybe.From("Countess");
var display = nickname.Match(
some => $"Nickname: {some}",
() => "No nickname on file");
TryGetValue
using Trellis;
Maybe<int> count = Maybe.From(3);
if (count.TryGetValue(out var value))
Console.WriteLine(value);
GetValueOrDefault
using Trellis;
Maybe<string> title = Maybe<string>.None;
var value = title.GetValueOrDefault("Untitled");
var lazyValue = title.GetValueOrDefault(() => "Generated title");
Warning
Value throws when the Maybe<T> is empty. Prefer Match, TryGetValue, or GetValueOrDefault(...).
Transforming Optional Values
This is where Maybe<T> starts to earn its keep.
Map
using Trellis;
Maybe<string> email = Maybe.From("ada@example.com");
Maybe<string> upper = email.Map(value => value.ToUpperInvariant());
Bind
Use Bind when the next step also returns a Maybe<T>.
using Trellis;
static Maybe<string> GetManagerEmail(string userId) =>
userId == "42" ? Maybe.From("manager@example.com") : Maybe<string>.None;
var email = Maybe.From("42")
.Bind(GetManagerEmail);
Where
using Trellis;
Maybe<int> quantity = Maybe.From(3);
Maybe<int> validQuantity = quantity.Where(value => value > 0);
Tap
using Trellis;
var maybeUser = Maybe.From("Ada")
.Tap(value => Console.WriteLine($"Found {value}"));
Or
using Trellis;
Maybe<string> preferred = Maybe<string>.None;
Maybe<string> legal = Maybe.From("Ada Lovelace");
var name = preferred.Or(legal).Or("Unknown");
Converting Maybe<T> to Result<T>
This is the most important bridge in day-to-day Trellis usage.
The problem it solves: sometimes “missing” is fine in the middle of the workflow, but becomes a real error at the boundary.
using Trellis;
Maybe<string> maybeEmail = Maybe<string>.None;
Result<string> emailResult = maybeEmail.ToResult(
Error.NotFound("Primary email address was not found"));
There is also a lazy overload when creating the error is expensive or needs runtime context.
using Trellis;
var result = Maybe<string>.None.ToResult(
() => Error.NotFound("Primary email address was not found"));
Converting Result<T> to Maybe<T>
Sometimes you want the opposite tradeoff: “keep the value if successful, otherwise treat it as missing.”
using Trellis;
Maybe<string> existing = Result.Success("Ada").ToMaybe();
Maybe<string> missing = Result.Failure<string>(Error.NotFound("User not found")).ToMaybe();
Use this only when dropping the error is the right thing to do.
Optional Input with Maybe.Optional(...)
A very common problem at system boundaries is “null is acceptable, but if a value is present, it must be valid.”
That is exactly what Maybe.Optional(...) is for.
Reference input
using Trellis;
static Result<string> NonEmpty(string value) =>
string.IsNullOrWhiteSpace(value)
? Result.Failure<string>(Error.Validation("Value is required", "nickname"))
: Result.Success(value);
string? input = "Countess";
Result<Maybe<string>> result = Maybe.Optional(input, NonEmpty);
Nullable value-type input
using Trellis;
static Result<int> Positive(int value) =>
value > 0
? Result.Success(value)
: Result.Failure<int>(Error.Validation("Value must be positive", "quantity"));
int? input = 3;
Result<Maybe<int>> result = Maybe.Optional(input, Positive);
What Maybe.Optional(...) does:
null/ no value -> success withMaybe<T>.None- value present and valid -> success with
Maybe.From(...) - value present and invalid -> failure with the validation error
Equality and Operators
This is another easy detail to miss.
Tip
Maybe<T> supports Equals, ==, and !=.
using Trellis;
Maybe<int> some = Maybe.From(42);
Maybe<int> none = Maybe<int>.None;
Console.WriteLine(some == 42); // True
Console.WriteLine(some == Maybe.From(42)); // True
Console.WriteLine(some != 0); // True
Console.WriteLine(none == Maybe<int>.None); // True
Console.WriteLine(some.Equals(Maybe.From(42))); // True
LINQ Query Syntax
Optional values often read nicely in query form.
using Trellis;
Maybe<string> first = Maybe.From("Ada");
Maybe<string> last = Maybe.From("Lovelace");
Maybe<string> fullName =
from f in first
from l in last
select $"{f} {l}";
If any step is None, the whole query becomes None.
Collection Helpers
Trellis also adds a few helpers that make working with collections of optional values pleasant.
TryFirst and TryLast
using Trellis;
var numbers = new[] { 1, 2, 3, 4 };
Maybe<int> first = numbers.TryFirst();
Maybe<int> even = numbers.TryFirst(n => n % 2 == 0);
Maybe<int> last = numbers.TryLast();
Choose
using Trellis;
IEnumerable<Maybe<string>> names =
[
Maybe.From("Ada"),
Maybe<string>.None,
Maybe.From("Grace")
];
IEnumerable<string> values = names.Choose();
IEnumerable<int> lengths = names.Choose(name => name.Length);
Maybe<T> and Unit Results
Sometimes the next question is: “what if my operation has no payload?”
For Trellis unit results:
- prefer
Result.Success()for a successfulResult<Unit> - use
new Unit()ordefaultif you need aUnitvalue explicitly - do not use
Unit.Value— that API does not exist
using Trellis;
Result<Unit> ok = Result.Success();
Unit unit = new Unit();
Unit alsoUnit = default;
Practical Rules of Thumb
- Use
Maybe<T>for optional domain values, especially value objects - Keep optionality on the containing model, not inside a value object's invariants
- Use
ToResult(...)when absence should become a real error - Use
Maybe.Optional(...)for boundary validation of optional inputs - Use
ToMaybe()only when you truly want to discard the error - Prefer safe readers like
Match,TryGetValue, andGetValueOrDefault(...)
Next Steps
- Read Error Handling for the errors typically paired with
ToResult(...) - Read Advanced Features for LINQ, tuple destructuring, and parallel flows