Types


Which type should I use?

You want to express… Use
A value that may be present or absent Maybe<T>
An operation that succeeds with a value or fails with a string message Result<T>
An operation that succeeds with a value or fails with a typed error Result<T, TError>
An operation with no return value that can succeed or fail Result
Accumulated validation errors (collect all, don’t short-circuit) Validation<T>
A value that is intrinsically one of two shapes (A or B) Either<TLeft, TRight>
A value that is one of three or four unrelated shapes OneOf<T1,T2,T3[,T4]>
A structured error value for APIs Error

Result<T>

A readonly struct for railway-oriented programming. An operation either succeeds with a value or fails with an error message — no exceptions needed for expected failures.

Creating Results

// Explicit factory methods
Result<int> ok  = Result<int>.Success(42);
Result<int> err = Result<int>.Failure("Something went wrong");

// Implicit conversion from value
Result<int> implicit = 42;

// Wrap any value
Result<string> wrapped = "hello".ToResult();

// Safe execution (catches exceptions)
Result<int> safe = ResultEx.TryExecute(() => int.Parse("abc"));
// Failure("Input string was not in a correct format.")

// Async safe execution
Result<string> data = await ResultEx.TryExecuteAsync(
    () => httpClient.GetStringAsync("/api/data"));

Inspecting Results

Result<int> result = GetResult();

if (result.IsSuccess)
    Console.WriteLine(result.Value);   // access the value

if (result.IsFailure)
    Console.WriteLine(result.Error);   // access the error message

int fallback = result.GetValueOrDefault(-1); // value or fallback

string display = result.ToString(); // "Success(42)" or "Failure(error)"

Railway-Oriented Pipeline (Synchronous)

Result<Order> result = GetUserId()              // Result<int>
    .Ensure(id => id > 0, "Invalid user ID")    // validate
    .Map(id => new OrderRequest(id))             // transform value
    .Bind(req => PlaceOrder(req))                // chain to another Result
    .Tap(order => logger.Log($"Order {order.Id} placed"))  // side-effect on success
    .TapError(err => logger.LogWarning(err))     // side-effect on failure
    .MapError(err => $"Order failed: {err}");    // transform error message

// Pattern match to extract the final value
string message = result.Match(
    onSuccess: order => $"Order #{order.Id} confirmed",
    onFailure: error => $"Error: {error}");

Railway-Oriented Pipeline (Async)

Instance async methods (when you already have a Result<T>):

Result<int> userId = GetUserId();

Result<Order> order = await userId
    .MapAsync(id => FetchUserAsync(id))          // async transform
    .BindAsync(user => CreateOrderAsync(user))   // async chain
    .TapAsync(o => SendConfirmationAsync(o))     // async side-effect
    .TapErrorAsync(e => LogErrorAsync(e));        // async error side-effect

Fluent extensions on Task<Result<T>> (full async pipeline):

Result<OrderConfirmation> confirmation = await GetUserIdAsync()    // Task<Result<int>>
    .EnsureAsync(id => id > 0, "Invalid ID")
    .MapAsync(id => LookupUserAsync(id))
    .BindAsync(user => PlaceOrderAsync(user))
    .TapAsync(order => NotifyAsync(order))
    .TapErrorAsync(err => AlertOpsAsync(err))
    .MapErrorAsync(err => $"Pipeline failed: {err}");

// Async pattern match
string msg = await GetUserIdAsync()
    .MatchAsync(
        onSuccess: id => $"Found user {id}",
        onFailure: err => $"Error: {err}");

Type Conversions

Result<int> result = Result<int>.Success(42);

// Result → Maybe (drops the error, keeps presence/absence)
Maybe<int> maybe = result.ToMaybe();         // Some(42)
Result<int>.Failure("err").ToMaybe();        // None

// Result → Either (Success → Right, Failure → Left)
Either<string, int> either = result.ToEither(); // Right(42)
Result<int>.Failure("err").ToEither();          // Left("err")

// Result → Validation (Success → Valid, Failure → Invalid)
Validation<int> validation = result.ToValidation(); // Valid(42)
Result<int>.Failure("err").ToValidation();          // Invalid(["err"])

Combining Results

Result<string> name  = Result<string>.Success("Alice");
Result<int>    age   = Result<int>.Success(30);

// Combine two results into a tuple
Result<(string, int)> combined = ResultEx.Combine(name, age);
// Success(("Alice", 30))

// Combine a collection
var results = new[] { Result<int>.Success(1), Result<int>.Success(2) };
Result<IReadOnlyList<int>> all = ResultEx.Combine(results);
// Success([1, 2])

// If any fails, the first error propagates
var mixed = new[] { Result<int>.Success(1), Result<int>.Failure("bad") };
Result<IReadOnlyList<int>> fail = ResultEx.Combine(mixed);
// Failure("bad")

Result (non-generic)

For void operations that succeed or fail but don’t return a value.

// Create
Result ok  = Result.Success();
Result err = Result.Failure("Operation failed");

// Safe execution
Result safe = ResultEx.TryExecute(() => File.Delete("temp.txt"));
Result asyncSafe = await ResultEx.TryExecuteAsync(() => SendEmailAsync());

// Inspect
if (ok.IsSuccess) Console.WriteLine("Done");
if (err.IsFailure) Console.WriteLine(err.Error);
Console.WriteLine(ok.ToString());  // "Success"
Console.WriteLine(err.ToString()); // "Failure(Operation failed)"

// ROP pipeline
Result result = Result.Success()
    .Tap(() => logger.Log("Starting"))
    .Ensure(() => CanProceed(), "Cannot proceed")
    .TapError(err => logger.LogWarning(err))
    .MapError(err => $"Wrapped: {err}");

// Bind to produce a Result<T>
Result<Order> order = Result.Success()
    .Bind(() => CreateOrder());

// Pattern match
string msg = result.Match(
    onSuccess: () => "All good",
    onFailure: error => $"Failed: {error}");

// Async pipeline on Task<Result>
Result final = await DoWorkAsync()       // Task<Result>
    .TapAsync(() => NotifyAsync())
    .TapErrorAsync(err => LogAsync(err))
    .EnsureAsync(() => IsValid(), "Invalid state")
    .MapErrorAsync(err => $"Pipeline: {err}");

string asyncMsg = await DoWorkAsync()
    .MatchAsync(
        onSuccess: () => "OK",
        onFailure: err => $"Error: {err}");

// Async bind to Result<T>
Result<int> value = await DoWorkAsync()
    .BindAsync(() => ComputeAsync());

Maybe<T>

A readonly struct representing an optional value — similar to Option in functional languages. Use Maybe<T> when a value might legitimately be absent (instead of returning null).

Creating

Maybe<string> some = Maybe<string>.Some("hello");
Maybe<string> none = Maybe<string>.None;

// Implicit conversion (null → None, non-null → Some)
Maybe<string> fromValue = "hello";        // Some("hello")
Maybe<string> fromNull  = (string?)null;  // None

Inspecting

Maybe<int> maybe = Maybe<int>.Some(42);

if (maybe.HasValue)
    Console.WriteLine(maybe.Value);  // 42

if (maybe.HasNoValue)
    Console.WriteLine("No value");

int safe = maybe.GetValueOrDefault(-1); // 42 (or -1 if None)
Console.WriteLine(maybe.ToString());    // "Some(42)" or "None"

Transformations

Maybe<string> name = Maybe<string>.Some("  Alice  ");

// Map — transform the inner value
Maybe<string> trimmed = name.Map(n => n.Trim()); // Some("Alice")

// Bind — chain to another Maybe
Maybe<User> user = name.Bind(n => FindUserByName(n)); // Some(User) or None

// Where — filter
Maybe<string> long = name.Where(n => n.Length > 10); // None (too short)

// Execute — side-effect (does nothing if None)
name.Execute(n => Console.WriteLine($"Hello, {n}!"));

Pattern Matching

Maybe<int> age = Maybe<int>.Some(25);

string message = age.Match(
    some: a => $"Age is {a}",
    none: () => "Age unknown");
// "Age is 25"

Type Conversions

Maybe<User> maybeUser = FindUser(42);

// Maybe → Result (None becomes Failure with your error message)
Result<User> result = maybeUser.ToResult("User not found");

// Maybe → Either (None becomes Left with your value)
Either<string, User> either = maybeUser.ToEither("User not found");

// Maybe → Validation (None becomes Invalid with your error message)
Validation<User> validation = maybeUser.ToValidation("User is required");

// Then continue with the full Result pipeline
string output = result
    .Map(u => u.Name)
    .Match(
        onSuccess: name => $"Found: {name}",
        onFailure: err => err);

Async Transformations

Maybe<int> userId = Maybe<int>.Some(123);

// Async map
Maybe<User> user = await userId.MapAsync(id => GetUserAsync(id));

// Async bind
Maybe<Profile> profile = await userId.BindAsync(id => GetProfileAsync(id));

// Async tap (side-effect)
await userId.TapAsync(id => LogAccessAsync(id));

New Methods (Enhanced API)

// OrElse - provide alternative Maybe
Maybe<string> name = GetName().OrElse(Maybe<string>.Some("Anonymous"));

// OrValue - convert None to Some with default
Maybe<int> count = GetCount().OrValue(0);

// Filter - alias for Where
Maybe<int> positive = GetNumber().Filter(n => n > 0);

// Flatten - unwrap nested Maybe
Maybe<Maybe<int>> nested = Maybe<Maybe<int>>.Some(Maybe<int>.Some(42));
Maybe<int> flat = Maybe<int>.Flatten(nested); // Some(42)

// Zip - combine two Maybes
Maybe<(string, int)> combined = Maybe<(string, int)>.Zip(
    Maybe<string>.Some("Alice"),
    Maybe<int>.Some(30));
// Some(("Alice", 30))

Either<TLeft, TRight>

A readonly struct representing a value that can be one of two types. By convention, Right represents success and Left represents failure/error. Unlike Result<T> which uses strings for errors, Either<L,R> allows you to use any type for the error.

Creating

// Create from left (error)
Either<ValidationError, User> left = Either<ValidationError, User>.FromLeft(
    new ValidationError("Invalid email"));

// Create from right (success)
Either<ValidationError, User> right = Either<ValidationError, User>.FromRight(
    new User { Name = "Alice" });

Pattern Matching

Either<ValidationError, User> result = CreateUser(request);

string message = result.Match(
    onLeft: error => $"Validation failed: {error.Message}",
    onRight: user => $"Created user: {user.Name}");

Transformations

Either<string, int> either = Either<string, int>.FromRight(42);

// Map - transform the Right value
Either<string, string> mapped = either.Map(n => n.ToString());
// Right("42")

// Bind - chain operations
Either<string, int> doubled = either.Bind(n =>
    Either<string, int>.FromRight(n * 2));
// Right(84)

// MapLeft - transform the Left value
Either<int, string> leftMapped = either.MapLeft(err => err.Length);

// Tap - side-effect on Right
either.Tap(n => Console.WriteLine($"Value: {n}"));

// TapLeft - side-effect on Left
either.TapLeft(err => Console.WriteLine($"Error: {err}"));

Async Operations

Either<string, int> either = Either<string, int>.FromRight(42);

// Async map
Either<string, User> user = await either.MapAsync(id => GetUserAsync(id));

// Async bind
Either<string, Profile> profile = await either.BindAsync(id =>
    GetProfileAsync(id));

Type Conversions

Either<string, int> either = Either<string, int>.FromRight(42);

// Either → Maybe (Right becomes Some, Left is discarded)
Maybe<int> maybe = either.ToMaybe();          // Some(42)

// Either → Result (Left mapped to error string)
Result<int> result = either.ToResult(err => $"Failed: {err}");
// Success(42)

Either<int, string>.FromLeft(404).ToResult(code => $"Error {code}");
// Failure("Error 404")

Use Cases

// Typed errors instead of strings
public Either<ValidationError, Order> CreateOrder(OrderRequest request)
{
    var validation = ValidateOrder(request);
    if (!validation.IsValid)
        return Either<ValidationError, Order>.FromLeft(
            new ValidationError(validation.Errors));

    var order = new Order { /* ... */ };
    return Either<ValidationError, Order>.FromRight(order);
}

// In controller
var result = CreateOrder(request);
return result.Match(
    onLeft: error => BadRequest(new { errors = error.Fields }),
    onRight: order => Ok(order));

OneOf<T1, T2[, T3[, T4]]>

A readonly struct discriminated union that holds exactly one value out of 2, 3, or 4 unrelated types. Unlike Either<TLeft, TRight>, no slot is privileged — use OneOf when the alternatives are peers (e.g. “the API returns a User, a ValidationError, or a NotFoundError”) rather than a success/failure split.

public OneOf<User, NotFoundError, ValidationError> GetUser(Guid id)
{
    if (!Guid.TryParse(id.ToString(), out _)) return new ValidationError("Bad id");
    var user = _repo.Find(id);
    if (user is null) return new NotFoundError(id);
    return user;  // implicit conversions select the right slot
}

// Exhaustive match
string message = GetUser(id).Match(
    user     => $"Hello, {user.Name}",
    notFound => $"User {notFound.Id} not found",
    invalid  => $"Bad request: {invalid.Message}");

// Side-effect dispatch
GetUser(id).Switch(
    user     => Log.Info($"Found {user.Name}"),
    notFound => Log.Warn($"Missing {notFound.Id}"),
    invalid  => Log.Warn($"Invalid input: {invalid.Message}"));

// Type predicates and typed accessors
var result = GetUser(id);
if (result.IsT0) return Ok(result.AsT0);
if (result.IsT1) return NotFound();
return BadRequest(result.AsT2.Message);

// Map a single slot
OneOf<string, NotFoundError> summary = result
    .MapT0(user => user.Name)                         // only available on OneOf<T1,T2>
    .MapT1(notFound => notFound /* same type */);

API at a glance

Member Available on Purpose
Index, Value all arities Zero-based slot index; value as object
IsT0IsTN all arities Type predicates
AsT0AsTN all arities Typed accessors (throw if wrong slot)
FromT0FromTN all arities Explicit factories (use when T1 == T2)
Implicit conversions from each T all arities OneOf<A, B> x = value;
Match<TResult>(…) all arities Exhaustive pattern match, returns a value
Switch(…) all arities Exhaustive pattern match, side effects
MapT0, MapT1 OneOf<T1, T2> only Transform one slot, preserve the other
Equals, ==, !=, GetHashCode, ToString all arities Value equality

Notes and caveats

  • The implicit conversions use source type identity. If the generic arguments are instantiated with the same type (e.g. OneOf<string, string>), use the explicit FromT0 / FromT1 factories — conversions are ambiguous otherwise.
  • null values are rejected by the factories and implicit conversions (ArgumentNullException).
  • MapT2 / MapT3 are deliberately omitted from the 3- and 4-arity variants to keep the API small; use Match instead when you need to rebuild a OneOf after transforming any slot.

Validation<T>

A readonly struct for accumulative validation — collects all validation errors instead of stopping at the first one. Perfect for form validation where you want to show all errors to the user.

Creating

// Valid result
Validation<User> valid = Validation<User>.Valid(new User { Name = "Alice" });

// Invalid result with errors
Validation<User> invalid = Validation<User>.Invalid(
    "Name is required",
    "Email is invalid",
    "Age must be 18+");

Accumulating Errors

public Validation<User> ValidateUser(UserRequest request)
{
    var errors = new List<string>();

    // Validate name
    if (string.IsNullOrWhiteSpace(request.Name))
        errors.Add("Name is required");
    if (request.Name?.Length > 100)
        errors.Add("Name must be 100 characters or less");

    // Validate email
    if (string.IsNullOrWhiteSpace(request.Email))
        errors.Add("Email is required");
    if (!IsValidEmail(request.Email))
        errors.Add("Email must be valid");

    // Validate age
    if (request.Age < 18)
        errors.Add("Age must be 18 or older");

    // Return all errors at once!
    if (errors.Count > 0)
        return Validation<User>.Invalid(errors);

    return Validation<User>.Valid(new User
    {
        Name = request.Name,
        Email = request.Email,
        Age = request.Age
    });
}

// Usage
var validation = ValidateUser(request);
if (validation.IsInvalid)
{
    // Returns ALL errors:
    // ["Name is required", "Email must be valid", "Age must be 18 or older"]
    return BadRequest(new { errors = validation.Errors });
}

var user = validation.Value;

Transformations

Validation<User> validation = ValidateUser(request);

// Map - transform the value if valid
Validation<UserDto> dto = validation.Map(user => new UserDto(user));

// Bind - chain validations
Validation<Order> order = validation.Bind(user => CreateOrder(user));

// Pattern match
string message = validation.Match(
    onValid: user => $"Created: {user.Name}",
    onInvalid: errors => $"Errors: {string.Join(", ", errors)}");

Combining Validations

var nameValidation = ValidateName(request.Name);
var emailValidation = ValidateEmail(request.Email);
var ageValidation = ValidateAge(request.Age);

// Combine - accumulates all errors from all validations
var combined = Validation<User>.Combine(
    nameValidation,
    emailValidation,
    ageValidation);

if (combined.IsInvalid)
{
    // Returns errors from ALL three validations!
    return BadRequest(combined.Errors);
}

Convert to Result

Validation<User> validation = ValidateUser(request);

// Convert to Result (combines all errors into single message)
Result<User> result = validation.ToResult();
// If invalid: Failure("Name is required; Email must be valid; Age must be 18+")

Async Operations

Validation<User> validation = Validation<User>.Valid(user);

// Async map
Validation<Profile> profile = await validation.MapAsync(u =>
    EnrichProfileAsync(u));

// Async bind
Validation<Order> order = await validation.BindAsync(u =>
    CreateOrderAsync(u));

LINQ Query Syntax Support

All functional types (Result<T>, Maybe<T>) now support LINQ query syntax for more readable functional composition!

Result<T> with LINQ

// Before: Nested Bind calls
var result = GetUser(id)
    .Bind(user => GetProfile(user.Id)
        .Bind(profile => GetSettings(profile.Id)
            .Map(settings => new UserViewModel(user, profile, settings))));

// After: LINQ query syntax
var result = from user in GetUser(id)
             from profile in GetProfile(user.Id)
             from settings in GetSettings(profile.Id)
             select new UserViewModel(user, profile, settings);

// More complex example
var orderResult =
    from userId in ValidateUserId(request.UserId)
    from user in GetUser(userId)
    from product in GetProduct(request.ProductId)
    from inventory in CheckInventory(product.Id)
    select new Order
    {
        UserId = user.Id,
        ProductId = product.Id,
        Quantity = request.Quantity
    };

Maybe<T> with LINQ

// Before: Nested Bind calls
var name = GetUser(id)
    .Bind(user => user.GetProfile()
        .Map(profile => profile.DisplayName));

// After: LINQ query syntax
var name = from user in GetUser(id)
           from profile in user.GetProfile()
           select profile.DisplayName;

// With filtering
var activeName =
    from user in GetUser(id)
    where user.IsActive
    from profile in user.GetProfile()
    where !string.IsNullOrEmpty(profile.DisplayName)
    select profile.DisplayName;

Mixing Result and Maybe

// Convert Maybe to Result, then use LINQ
var result =
    from user in GetUser(id).ToResult("User not found")
    from profile in GetProfile(user.Id)
    from settings in GetSettings(profile.Id)
    select new UserData(user, profile, settings);

Benefits

  • More readable - Looks like regular LINQ queries
  • Familiar syntax - Developers already know LINQ
  • Composable - Easy to add more steps
  • Type-safe - Full IntelliSense support
  • Short-circuiting - Stops at first failure (Result) or None (Maybe)

Result<T, TError>

A typed-error variant of Result<T>. Use this when the error is more than just a string — for example when the caller needs to branch on specific error categories (NotFound, Validation, Unauthorized) or needs structured error data.

Result<User, Error> LookupUser(int id)
{
    User? user = _db.Find(id);
    return user is null
        ? Error.NotFound($"User {id} was not found.")
        : user;
}

Result<UserDto, Error> result = LookupUser(42)
    .Map(u => new UserDto(u))
    .Ensure(u => u.IsActive, Error.Forbidden("User is disabled."));

string response = result.Match(
    success: dto => JsonSerializer.Serialize(dto),
    failure: err => err.Message);

The API mirrors Result<T>: Map, MapError, Bind, Match, Tap, TapError, Ensure, Recover, plus LINQ query syntax and async overloads.

Error

A structured error record with common factory methods.

Error.NotFound("User 42 not found.")
Error.Validation("Name is required.")
Error.Unauthorized("Token expired.")
Error.Forbidden("Insufficient privileges.")
Error.Conflict("Email already registered.")
Error.Unexpected("Database connection lost.")

Each error carries a Code (string) and a Message.


Combinators

Helpers for working with collections of functional types — commonly Sequence and Traverse.

// Flip a collection of Results into a Result of a collection.
// Short-circuits at the first failure.
IEnumerable<Result<int>> parsed = inputs.Select(Result<int>.TryExecute);
Result<IReadOnlyList<int>> all = Combinators.Sequence(parsed);

// Apply a Result-returning function and combine the results in one step.
Result<IReadOnlyList<int>> parsed = Combinators.Traverse(inputs, Parse);

// Works for Maybe<T> and Validation<T> too:
Maybe<IReadOnlyList<int>> maybeAll = Combinators.Sequence(maybes);
Validation<IReadOnlyList<int>> valid = Combinators.Sequence(validations);

For Validation<T>, Sequence accumulates all errors rather than short-circuiting.