Advanced Features
Once you know Map, Bind, and Ensure, the next question is usually: how do I keep the same style when the workflow gets more complex?
This article covers the Trellis features that help when you need to:
- branch cleanly at the end of a pipeline
- combine several successful values without tuple plumbing
- capture exceptions from throw-happy APIs
- run independent async work in parallel
- write more declarative pipelines with LINQ
Start Here: a Compact Example
using Trellis;
var result = Result.Success("Ada")
.Combine(Result.Success("Lovelace"))
.Map((first, last) => $"{first} {last}")
.Match(
onSuccess: name => $"Hello, {name}",
onFailure: error => $"Failed: {error.Code}");
That example uses three advanced ideas at once:
- tuple-aware
Combine(...) - tuple-aware
Map(...) - terminal pattern matching with
Match(...)
Pattern Matching: Make the End of the Pipeline Obvious
The problem pattern matching solves is simple: after a chain of operations, you need one clear place to decide what happens on success and failure.
Match
using Trellis;
var description = Result.Success("Ada")
.Match(
onSuccess: value => $"Success: {value}",
onFailure: error => $"Failure: {error.Code}");
MatchAsync
using Trellis;
public record User(string Id, string Name);
static Task<Result<User>> GetUserAsync(string id) =>
Task.FromResult(id == "42"
? Result.Success(new User(id, "Ada"))
: Result.Failure<User>(Error.NotFound($"User {id} not found", id)));
var message = await GetUserAsync("42").MatchAsync(
onSuccess: user => $"Loaded {user.Name}",
onFailure: error => $"Failed: {error.Code}");
Tip
Use Match(...) when you want one final value. Use MatchError(...) from the error-handling guide when different error types need different outcomes.
Tuple Destructuring: Combine Values Without Manual Unpacking
When several validations or lookups all need to succeed, the annoying part is usually the tuple handling. Trellis solves that by letting Bind, Map, Tap, and Match destructure combined tuples for you.
Combine(...) + Bind(...)
using Trellis;
public record OrderDraft(string CustomerId, string Sku, int Quantity);
var draft = Result.Success("customer-42")
.Combine(Result.Success("sku-123"))
.Combine(Result.Success(3))
.Bind((customerId, sku, quantity) =>
Result.Success(new OrderDraft(customerId, sku, quantity)));
Combine(...) + Map(...)
using Trellis;
var summary = Result.Success("Ada")
.Combine(Result.Success("Lovelace"))
.Combine(Result.Success("admin"))
.Map((first, last, role) => $"{first} {last} ({role})");
Combine(...) + Match(...)
using Trellis;
var message = Result.Success("Ada")
.Combine(Result.Success("Lovelace"))
.Match(
onSuccess: (first, last) => $"User: {first} {last}",
onFailure: error => error.Detail);
Note
Generated tuple overloads support arities from 2 through 9.
Exception Capture: Keep Throwing APIs at the Edge
Many .NET APIs still throw exceptions for normal operational problems. Trellis gives you a clean bridge so the rest of your code can stay in Result<T>.
Result.Try(...)
using Trellis;
static Result<string> LoadFile(string path) =>
Result.Try(() => File.ReadAllText(path));
var content = LoadFile("settings.json")
.Ensure(text => !string.IsNullOrWhiteSpace(text), Error.BadRequest("settings.json is empty"));
Result.TryAsync(...)
using Trellis;
static Task<Result<string>> LoadFileAsync(string path) =>
Result.TryAsync(() => File.ReadAllTextAsync(path));
var content = await LoadFileAsync("settings.json")
.EnsureAsync(text => Task.FromResult(!string.IsNullOrWhiteSpace(text)), Error.BadRequest("settings.json is empty"));
Custom exception mapping
using Trellis;
var result = Result.Try(
() => File.ReadAllText("settings.json"),
exception => exception switch
{
FileNotFoundException => Error.NotFound("settings.json was not found"),
UnauthorizedAccessException => Error.Forbidden("Access denied"),
_ => Error.Unexpected(exception.Message)
});
Tip
A good default rule is: use exceptions at the integration boundary, then convert them once.
Parallel Operations: Run Independent Async Work Together
The problem ParallelAsync(...) solves is wasted time. If three async operations do not depend on each other, running them one after another is just latency.
The basic pattern
using Trellis;
public record User(string Id, string Name);
public record Order(string Id);
public record Preferences(bool DarkMode);
public record Dashboard(User User, IReadOnlyList<Order> Orders, Preferences Preferences);
static Task<Result<User>> GetUserAsync(string userId) =>
Task.FromResult(Result.Success(new User(userId, "Ada")));
static Task<Result<IReadOnlyList<Order>>> GetOrdersAsync(string userId) =>
Task.FromResult(Result.Success<IReadOnlyList<Order>>([new Order("ord-1")]));
static Task<Result<Preferences>> GetPreferencesAsync(string userId) =>
Task.FromResult(Result.Success(new Preferences(true)));
var combined = await Result.ParallelAsync(
() => GetUserAsync("42"),
() => GetOrdersAsync("42"),
() => GetPreferencesAsync("42"))
.WhenAllAsync();
var dashboard = combined.Bind((user, orders, preferences) =>
Result.Success(new Dashboard(user, orders, preferences)));
What ParallelAsync(...) actually does
- accepts factory functions like
Func<Task<Result<T>>> - invokes those factories immediately
- returns a tuple of tasks
- lets
WhenAllAsync()wait for all of them - combines successes into a tuple result
- combines failures using normal Trellis error aggregation rules
Warning
WhenAllAsync() combines failed Result<T> values, but if one of the tasks itself faults or is canceled, that exception still escapes.
When to use it
Use ParallelAsync(...) when:
- operations are independent
- operations are safe to run concurrently
- latency matters
- collecting multiple failures is useful
Avoid it when:
- step B depends on step A
- operations mutate shared state unsafely
- ordering is part of the business rule
LINQ Query Syntax: Write Sequential Intent Clearly
LINQ syntax is handy when your pipeline is conceptually “do this, then that, then that.”
Result LINQ
using Trellis;
var confirmation =
from customerId in Result.Success("customer-42")
from sku in Result.Success("sku-123")
from quantity in Result.Success(3)
.Ensure(value => value > 0, Error.Validation("Quantity must be positive", "quantity"))
select $"{customerId}:{sku}:{quantity}";
where in LINQ
using Trellis;
var result =
from name in Result.Success("Ada")
where name.Length >= 3
select name.ToUpperInvariant();
Note
In a Result LINQ expression, where uses Trellis' generic “filtered out” failure. If you need a domain-specific message, prefer Ensure(...).
Maybe LINQ
Maybe<T> supports LINQ too, which makes optional flows much easier to read.
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}";
Result-to-Maybe Conversion
Sometimes the only question is “did I get a value?” — not “why did it fail?” That is what ToMaybe() is for.
using Trellis;
Maybe<string> cachedName = Result.Success("Ada").ToMaybe();
Maybe<string> missingName = Result.Failure<string>(Error.NotFound("User not found")).ToMaybe();
And the async version:
using Trellis;
Maybe<string> value = await Task.FromResult(Result.Success("Ada")).ToMaybeAsync();
Practical Rules of Thumb
- Use pattern matching to make the end of the pipeline obvious
- Use tuple destructuring instead of manual tuple unpacking
- Use
Try/TryAsyncat integration boundaries - Use
ParallelAsync(...).WhenAllAsync()only for truly independent async work - Use LINQ syntax when it reads more clearly than nested
Bind(...) - Use
ToMaybe()only when discarding the error is intentional
Next Steps
- Read Error Handling for type-specific matching and aggregation
- Read Why Maybe? for domain optionality and
ToResult(...)