Mtsc.Functional 0.0.3

dotnet add package Mtsc.Functional --version 0.0.3
                    
NuGet\Install-Package Mtsc.Functional -Version 0.0.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Mtsc.Functional" Version="0.0.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Mtsc.Functional" Version="0.0.3" />
                    
Directory.Packages.props
<PackageReference Include="Mtsc.Functional" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Mtsc.Functional --version 0.0.3
                    
#r "nuget: Mtsc.Functional, 0.0.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Mtsc.Functional@0.0.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Mtsc.Functional&version=0.0.3
                    
Install as a Cake Addin
#tool nuget:?package=Mtsc.Functional&version=0.0.3
                    
Install as a Cake Tool

mtsc-functional

NuGet License: MIT

A small set of functional patterns for C#: Option<T>, Result<T, TError>, and Unit, with Map/Bind/Filter/MapError composition (sync and async) and no .Value getter to shoot yourself with.

Both Option<T> and Result<T, TError> are readonly structs — no heap allocation, and their default value (default(Option<T>), default(Result<T,TError>)) is always a safe empty/failure state, never a trap.

Why

null and thrown exceptions hide the "this might not work" case from the type system — the compiler lets you ignore it, and you find out at runtime, usually the hard way. Option<T> and Result<T, TError> put that case in the signature itself, so it's visible at every call site and the compiler holds you to handling it.

// Imperative: the possibility of absence is invisible until it happens.
User user = repository.FindById(id);         // null? throws? you have to go read the implementation to know
if (user == null) { /* ... */ }

// Functional: the signature says it plainly, and Map/Bind won't let you forget.
Option<User> user = repository.FindById(id); // Option<User> — the type itself says "might not be there"
string name = user.Map(u => u.Name).GetValueOrDefault("unknown");

The same idea applies to errors: instead of throwing and hoping every caller remembers a try/catch, a method returning Result<T, TError> makes "this can fail, and here's how" part of what it returns — and Map/Bind/Match mean you compose those failure-aware steps without writing a single if (x == null) or try/catch yourself.

The mental shift is mostly this: stop asking "did it work?" after the fact with a null check or a catch block, and instead keep transforming the possibility of a value/success all the way through your pipeline, only asking "did it work?" once, at the very end, via Match.

Why no .Value getter

Neither Option<T> nor Result<T, TError> exposes a .Value property. This is intentional: a getter that throws when the value isn't present is easy to call unsafely and hides the failure path from the type system. Instead, use Match to handle both branches explicitly, or GetValueOrDefault(fallback) on Option<T> when a fallback is good enough. This keeps the "what if it's missing/failed" question visible at every call site instead of deferred to a runtime exception.

Install

dotnet add package Mtsc.Functional

Targets net10.0.

Contents

Option<T>

Option<T> represents a value that may or may not be present, avoiding null.

using Mtsc.Functional;

Option<int> some = Option<int>.Some(42);
Option<int> none = Option<int>.None;

// Or infer Some/None from a nullable value instead of choosing explicitly.
Option<string> maybeName = Option<string>.Create(GetNameOrNull()); // null -> None, else Some(value)

Create is handy right at the boundary where a nullable value enters your code — a dictionary lookup, a DTO field, an external API result:

Dictionary<string, string> headers = new() { ["X-Request-Id"] = "abc-123" };

Option<string> found = Option<string>.Create(headers.GetValueOrDefault("X-Request-Id"));   // "abc-123" -> Some("abc-123")
Option<string> missing = Option<string>.Create(headers.GetValueOrDefault("X-Trace-Id"));   // not present -> None

Compose with Map, Bind, and Filter — none of them ever invoke your function on a None:

Option<string> greeting = some
    .Filter(v => v > 0)
    .Map(v => v * 2)
    .Bind(v => v > 0 ? Option<string>.Some($"positive: {v}") : Option<string>.None);

Read the value out with Match (both branches, exhaustive) or GetValueOrDefault (a fallback, no branching):

string message = greeting.Match(
    onSome: v => v,
    onNone: () => "nothing here");

int fallback = some.GetValueOrDefault(-1);

Chain several steps that can each come up empty — the whole thing collapses to None the moment any step does, with no nested ifs and no null-checking at each step:

static Option<string> ValidateEmail(string? input) =>
    Option<string>.Create(input)
        .Filter(s => !string.IsNullOrWhiteSpace(s))
        .Filter(s => s.Contains('@'))
        .Map(s => s.Trim().ToLowerInvariant());

Option<string> valid = ValidateEmail("  User@Example.com ");   // Some("user@example.com")
Option<string> invalid = ValidateEmail("not-an-email");        // None
Option<string> missing = ValidateEmail(null);                  // None — Create handles the null itself

MapAsync/BindAsync mirror Map/Bind for async pipelines, and work whether you're chaining off an Option<T> directly or off a pending Task<Option<T>> — no manual await needed in the middle of a pipeline:

Option<User> user = await LookupUserAsync(id);                 // Task<Option<User>>
Option<string> emailDomain = await user
    .MapAsync(u => NormalizeAsync(u.Email))                    // Task<Option<User>> -> Task<Option<string>>
    .MapAsync(email => email.Split('@')[1]);                   // sync projector, still chains fine

// BindAsync chains a step that can itself come up empty, flattening the result the same way sync Bind does.
Option<DomainInfo> domainInfo = await emailDomain.BindAsync(domain => LookupDomainInfoAsync(domain));

Option<T> serializes with System.Text.Json out of the box — no setup needed, the converter is applied automatically. Some(value) serializes as the raw value itself and None as null, the same shape a plain nullable field would use, since that's exactly what Option<T> replaces:

public class PersonDto
{
    public string Name { get; set; } = "";
    public Option<string> Nickname { get; set; } // just another nullable-ish field on the wire
}

JsonSerializer.Serialize(new PersonDto { Name = "Alice", Nickname = Option<string>.Some("Al") });
// {"Name":"Alice","Nickname":"Al"}

JsonSerializer.Serialize(new PersonDto { Name = "Bob", Nickname = Option<string>.None });
// {"Name":"Bob","Nickname":null}

Result<T, TError> intentionally has no built-in JSON converter — a Result is meant to be resolved with Match at a boundary (an API endpoint, a message handler) into whatever shape that boundary actually needs, not serialized as-is. Serializing it directly would encourage leaking the "success or typed error" shape straight into a response body instead of deciding what each case means there. See Putting it together for the intended pattern.

Combine a whole sequence of Options with Sequence/Traverse — useful when every element has to be present for the result to mean anything:

IEnumerable<Option<int>> maybeScores = [Option<int>.Some(10), Option<int>.Some(20), Option<int>.Some(30)];
Option<IReadOnlyList<int>> allScores = maybeScores.Sequence(); // Some([10, 20, 30])

// Traverse = project + Sequence in one pass, without materializing the intermediate Options.
Option<IReadOnlyList<int>> parsed = rawInputs.Traverse(s => int.TryParse(s, out var v) ? Option<int>.Some(v) : Option<int>.None);

The moment any element is None, Sequence/Traverse stop looking at the rest — later elements (or the selector, for Traverse) are never evaluated. Result<T, TError> has the same two operations, stopping at the first failure and returning it.

Result<T, TError>

Result<T, TError> represents either a success value or a typed error, for operations that can fail without throwing.

using Mtsc.Functional;

Result<int, string> success = Result<int, string>.Success(10);
Result<int, string> failure = Result<int, string>.Failure("invalid input");

// Or infer Success/Failure from a nullable value plus the error to use if it's missing.
Result<int, string> parsedInput = Result<int, string>.Create(TryParseOrNull(input), "invalid input");

Create fits a "try to get a value, and here's the error if there isn't one" shape without an explicit if:

static int? TryParseOrNull(string input) => int.TryParse(input, out var value) ? value : null;

Result<int, string> ok = Result<int, string>.Create(TryParseOrNull("42"), "not a number");     // Success(42)
Result<int, string> bad = Result<int, string>.Create(TryParseOrNull("nope"), "not a number");   // Failure("not a number")

// Works well right after a repository/lookup call too.
User? user = repository.FindById(id);
Result<User, string> userResult = Result<User, string>.Create(user, $"user {id} not found");

Compose with Map (transform the success value), Bind (chain a step that can itself fail), and MapError (transform the error, leaving success alone):

Result<int, string> computed = success
    .Map(v => v * 2)
    .Bind(v => v > 100 ? Result<int, string>.Failure("too large") : Result<int, string>.Success(v))
    .MapError(e => $"pipeline failed: {e}");

string message = computed.Match(
    onSuccess: v => $"got {v}",
    onFailure: e => $"failed: {e}");

Ensure turns a success into a failure if it doesn't satisfy a predicate — the Filter that Result<T,TError> doesn't otherwise have, since unlike Option<T>'s Filter, it needs you to supply the error to fail with:

Result<int, string> validated = Result<int, string>.Success(-5)
    .Ensure(v => v > 0, "must be positive"); // Failure("must be positive")

MapAsync/BindAsync work the same way as Option<T>'s — off a Result<T,TError> directly, or off a pending Task<Result<T,TError>>, sync or async projector, no manual await needed mid-pipeline:

Result<int, string> userId = Result<int, string>.Create(TryParseOrNull(raw), "invalid id");

Result<string, string> userName = await userId.MapAsync(id => LoadNameAsync(id));       // Task<Result<string, string>>
Result<User, string> user = await userName.BindAsync(name => FindUserByNameAsync(name)); // async step that can itself fail

Result.Try/Result.TryAsync wrap exception-throwing code (yours or a library's) into a Result<T, Exception> instead of requiring try/catch at the call site:

Result<int, Exception> parsed = Result.Try(() => int.Parse("not a number"));

Result<string, Exception> response = await Result.TryAsync(() => httpClient.GetStringAsync(url, cancellationToken));

OperationCanceledException (including TaskCanceledException) is not captured by Try/TryAsync — it propagates to the caller as usual, since cancellation is control flow, not a business failure.

Then/Tap (and their async counterparts) build a fluent, exception-catching pipeline directly on Result<T, Exception> — every step is auto-wrapped in the same catching Result.Try uses, so nothing thrown mid-pipeline can escape uncaught:

Result<int, Exception> processed = Result.Try(() => ParseInput(raw))
    .Tap(v => logger.LogInformation("parsed: {Value}", v))
    .Then(v => Option<int>.Some(v).Filter(x => x > 0))   // Then flows into a Result<Option<int>, Exception>...
    .Then(opt => opt.Map(x => x * 2));                    // ...compose it further with Option<T>'s own Map/Bind

Option<int> asOption = processed.ToOption(); // discards the exception if it failed

Then/Tap are only available on Result<T, Exception> specifically (same convention as Result.Try) — for a pipeline with a custom error type throughout, use Map/Bind/MapError instead, which work with any TError but don't auto-catch exceptions.

ThenAsync/TapAsync are the async counterparts — same auto-catching, chainable off a Result<T,Exception> directly or off a pending Task<Result<T,Exception>>:

Result<RelatedData, Exception> processedAsync = await Result.Try(() => ParseInput(raw))
    .TapAsync(v => LogAsync(v))                 // async side effect, still auto-catching
    .ThenAsync(v => FetchRelatedDataAsync(v));   // async transform, still auto-catching

Going the other direction from ToOption — turning an Option<T> into a Result<T, TError> — uses Match with the error to use when it's None:

Option<User> maybeUser = repository.FindById(id);

Result<User, string> userResult = maybeUser.Match(
    onSome: u => Result<User, string>.Success(u),
    onNone: () => Result<User, string>.Failure($"user {id} not found"));

Unit

Unit is a zero-information placeholder for "no value" in functional code — the analog of void for a Func<T> instead of an Action, useful when a Result<Unit, TError> or Option<Unit> is more composable than a bare method that returns nothing:

Result<Unit, string> Validate(Order order)
    => order.Items.Count > 0
        ? Result<Unit, string>.Success(Unit.Value)
        : Result<Unit, string>.Failure("order has no items");

Equality and string representation

Option<T>, Result<T, TError>, and Unit all implement value equality (Equals, GetHashCode, ==, !=) and a readable ToString() — useful for assertions in tests, dictionary keys, logging, and debugging without reaching for Match just to compare or print:

Option<int>.Some(5) == Option<int>.Some(5);   // true — same state, same value
Option<int>.Some(5) == Option<int>.None;      // false
Option<int>.Some(5).ToString();               // "Some(5)"
Option<int>.None.ToString();                  // "None"

Result<int, string>.Success(5) == Result<int, string>.Success(5); // true
Result<int, string>.Failure("bad").ToString();                     // "Failure(bad)"

Unit.Value == Unit.Value; // true — Unit has exactly one possible value, always equal to itself

Putting it together

A realistic flow mixing most of the library: parse input, look up a record via Option, validate it, and keep every failure typed as a string from the first step onward.

public Result<OrderSummary, string> ProcessOrder(string rawOrderId)
{
    return Result.Try(() => Guid.Parse(rawOrderId))
        .MapError(ex => $"invalid order id: {ex.Message}")              // Result<Guid, Exception> -> Result<Guid, string>
        .Bind(id => Option<Order>.Create(repository.FindOrder(id))
            .Match(
                onSome: order => Result<Order, string>.Success(order),
                onNone: () => Result<Order, string>.Failure($"order {id} not found")))
        .Bind(order => order.Items.Count > 0
            ? Result<Order, string>.Success(order)
            : Result<Order, string>.Failure("order has no items"))
        .Map(Summarize);
}

Every step that can fail returns early with a specific, typed message — Map/Bind skip the rest of the chain the moment one does, so ProcessOrder's body reads top-to-bottom as the happy path, with the failure handling living in each step instead of scattered if/throw checks.

At the boundary (a controller action, a message handler), Match turns the outcome into whatever your framework needs — here, an ASP.NET Core minimal API endpoint:

app.MapGet("/orders/{id}", (string id, OrderService service) =>
    service.ProcessOrder(id).Match<IResult>(
        onSuccess: summary => Results.Ok(summary),
        onFailure: error => Results.NotFound(error)));

API reference

Option<T>

Member Description
Option<T>.Some(T value) Wraps a non-null value. Throws ArgumentNullException on null.
Option<T>.None The empty option. Same value as default(Option<T>).
Option<T>.Create(T? value) None if value is null, Some(value) otherwise.
IsSome / IsNone State flags.
GetValueOrDefault(T fallback) Unwraps or returns fallback.
Match(onSome, onNone) Exhaustive pattern match.
Map(map) Transforms the value if Some.
Bind(bind) Chains an Option-returning function if Some, flattening the result.
Filter(predicate) Keeps Some only if predicate holds; otherwise None.
MapAsync / BindAsync Async Map/Bind, on Option<T> or Task<Option<T>>, sync or async projector.
IEnumerable<Option<T>>.Sequence() Some(all values) if every element is Some; otherwise None at the first one that isn't (stops there).
IEnumerable<T>.Traverse(selector) Projects with an Option-returning selector, then Sequence()s the result — stops calling selector at the first None.
(automatic) System.Text.Json support Some(value) serializes as the raw value; None as null. No setup needed.
Equals / GetHashCode / == / != Value equality — two Somes are equal iff their values are; None == None.
ToString() "Some(value)" or "None".

Result<T, TError>

Member Description
Result<T, TError>.Success(T value) Wraps a non-null success value. Throws ArgumentNullException on null.
Result<T, TError>.Failure(TError error) Wraps a non-null error. Throws ArgumentNullException on null.
Result<T, TError>.Create(T? value, TError error) Failure(error) if value is null, Success(value) otherwise.
IsSuccess / IsFailure State flags.
Match(onSuccess, onFailure) Exhaustive pattern match.
Map(map) Transforms the success value if IsSuccess.
Bind(bind) Chains a Result-returning function if IsSuccess, flattening the result.
MapError(mapError) Transforms the error if IsFailure.
Ensure(predicate, error) Keeps a success if predicate holds; otherwise turns it into Failure(error). No-op on an existing failure.
MapAsync / BindAsync Async Map/Bind, on Result<T,TError> or Task<Result<T,TError>>, sync or async projector.
Result.Try(Func<T> action) Runs action, returns Result<T, Exception>. Cancellation propagates.
Result.TryAsync(Func<Task<T>> action) Async version of Try.
ToOption() Converts to Option<T>: Some(value) if success, None if failure. Discards the error.
Then(then) / Tap(action) On Result<T, Exception> only. Auto-catching Map/side-effect step — exceptions become the failure instead of propagating.
ThenAsync / TapAsync Async Then/Tap, on Result<T,Exception> or Task<Result<T,Exception>>, sync or async projector.
IEnumerable<Result<T,TError>>.Sequence() A success with all values if every element succeeds; otherwise the first failure encountered (stops there).
IEnumerable<T>.Traverse(selector) Projects with a Result-returning selector, then Sequence()s the result — stops calling selector at the first failure.
Equals / GetHashCode / == / != Value equality — two successes are equal iff their values are; two failures iff their errors are.
ToString() "Success(value)" or "Failure(error)".

Unit

Member Description
Unit.Value The single Unit instance. All instances are equal.
Equals / GetHashCode / == / != Always equal — Unit carries no information.
ToString() "()".
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.0.3 98 8/26/2026
0.0.2 93 8/26/2026
0.0.1 102 8/25/2026