Table of Contents

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 / TryAsync at 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(...)