RokyZevon.OpResult 0.3.0

dotnet add package RokyZevon.OpResult --version 0.3.0
                    
NuGet\Install-Package RokyZevon.OpResult -Version 0.3.0
                    
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="RokyZevon.OpResult" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="RokyZevon.OpResult" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="RokyZevon.OpResult" />
                    
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 RokyZevon.OpResult --version 0.3.0
                    
#r "nuget: RokyZevon.OpResult, 0.3.0"
                    
#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 RokyZevon.OpResult@0.3.0
                    
#: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=RokyZevon.OpResult&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=RokyZevon.OpResult&version=0.3.0
                    
Install as a Cake Tool

OpResult

English | 简体中文

OpResult is a small .NET Result Pattern library for explicit Ok and Err business flows. It is Native AOT compatible.

It provides two first-class result containers:

  • OpResult for operations that either succeed without a payload or fail with an OpError.
  • OpResult<T> for operations that either succeed with a non-null payload or fail with an OpError.

OpError carries a public Message and optional InnerError. It is an error details object produced by OpResults.Err(...) and can be converted to a failed OpResult or OpResult<T>.

Installation

dotnet add package RokyZevon.OpResult

The package includes the runtime library and the default Roslyn analyzers. Analyzer diagnostics are part of the default OpResult semantics and also run in command-line builds.

Analyzer Diagnostics

OpResult includes analyzer diagnostics for the core result-flow rules:

  • OPRESULT001: read Value only after proving IsOk.
  • OPRESULT002: read Error only after proving IsErr.
  • OPRESULT003: use IsOk / IsErr, not null or empty-message pseudo branch tests.
  • OPRESULT004: consume returned OpResult values.
  • OPRESULT005: preserve OpError chains with ToErr or OpResults.Err(message, innerError).

Default severity for first-version diagnostics is warning.

Bad example:

OpResult<User> result = FindUser(userId);
return result.Value.DisplayName; // OPRESULT001

Good example:

OpResult<User> result = FindUser(userId);

if (result.IsErr)
{
    return $"Could not load user: {result.Error.Message}";
}

return result.Value.DisplayName;

Configure diagnostic severity with .editorconfig:

[*.cs]
dotnet_diagnostic.OPRESULT001.severity = error
dotnet_diagnostic.OPRESULT004.severity = none

Quick Start

Return OpResult<T> from operations that can fail. For traditional .NET short-circuit flow, guard with IsErr or IsOk, then read Error or Value directly on the verified branch.

public string GetUserDisplayName(Guid userId)
{
    OpResult<User> result = FindUser(userId);

    if (result.IsErr)
    {
        return $"Could not load user: {result.Error.Message}";
    }

    User user = result.Value;
    return $"Loaded {user.DisplayName}.";
}

For multi-step workflows, chain asynchronous result-producing steps with ThenAsync, then await the pipeline and use Match when the final branch mapping is synchronous.

public async Task<string> GetUserDisplayNameAsync(Guid userId)
{
    OpResult<User> result = await ValidateUserIdAsync(userId)
        .ThenAsync(() => LoadUserAsync(userId))
        .ThenAsync(user => EnsureActiveAsync(user));

    return result.Match(
        onOk: user => $"Loaded {user.DisplayName}.",
        onErr: error => $"Could not load user: {error.Message}");
}

Task<OpResult> ValidateUserIdAsync(Guid userId) =>
    validationService.ValidateUserIdAsync(userId);

Task<OpResult<User>> LoadUserAsync(Guid userId) =>
    repository.LoadUserAsync(userId);

Task<OpResult<User>> EnsureActiveAsync(User user) =>
    userPolicy.EnsureActiveAsync(user);

Usage

Choosing the Right API

Use this When you need to
IsOk / IsErr + Value / Error Use traditional .NET branching, early returns, short-circuit flow, or direct local access to the success value or error details.
Then / ThenAsync Continue to another operation that can fail. Err short-circuits and the continuation is not called.
Match Finish the workflow when both branch handlers are synchronous.
MatchAsync Finish the workflow when either branch handler calls asynchronous work and returns Task.
OnOk / OnErr Run logging, metrics, auditing, or other side effects without changing the result.
TryInvoke / TryInvokeAsync Adapt exception-throwing boundary code into OpResult / OpResult<T>.

Creating Results

Use OpResult for command-style operations that do not produce a success value:

OpResult SaveAuditLog(string text)
{
    if (string.IsNullOrWhiteSpace(text))
    {
        return OpResults.Err("Audit text is required.");
    }

    File.AppendAllText("audit.log", text);
    return OpResults.Ok();
}

Use OpResult<T> for query-style operations that produce a non-null success value:

OpResult<User> FindUser(Guid id)
{
    User? user = repository.Find(id);

    return user is null
        ? OpResults.Err("User was not found.")
        : OpResults.Ok(user);
}

A non-null T can also be returned directly as a successful OpResult<T>:

OpResult<int> CountActiveUsers()
{
    return repository.CountActiveUsers();
}

Reading Value and Error After Guards

Value and Error are first-class APIs for direct branch handling. Check the result branch first, then read the matching property.

string FormatUser(Guid userId)
{
    OpResult<User> result = FindUser(userId);

    if (result.IsErr)
    {
        logger.Warn(result.Error.Message);
        return "User unavailable.";
    }

    User user = result.Value;
    return user.DisplayName;
}

For OpResult without a success payload, read Error on the failed branch:

void SaveAuditOrLog(string text)
{
    OpResult saved = SaveAuditLog(text);

    if (saved.IsErr)
    {
        logger.Warn(saved.Error.Message);
        return;
    }

    logger.Info("Audit log saved.");
}

Wrapping Errors with InnerError

Use InnerError when an outer operation needs to preserve the error from a lower-level operation. After an IsErr guard, result.Error.ToErr(...) creates a new outer OpError and keeps the original error as InnerError.

OpResult<UserProfile> LoadProfile(Guid userId)
{
    OpResult<User> userResult = LoadUser(userId);

    if (userResult.IsErr)
    {
        return userResult.Error.ToErr("Could not load profile.");
    }

    return profileRepository.Load(userResult.Value.Id);
}

If you already have the inner error and need to create an outer error directly, use OpResults.Err(message, innerError):

if (userResult.IsErr)
{
    return OpResults.Err("Could not load profile.", userResult.Error);
}

For one-line display or logging, ToString() returns the chain from outer error to inner error and skips empty messages:

OpError error = OpResults.Err("User was not found.")
    .ToErr("Could not load profile.");

logger.LogError("{Error}", error);

Expected display:

Could not load profile. -> User was not found.

If every message in the chain is empty, ToString() returns "<error>". ToString() is for human-readable display, not a stable parsing protocol.

Chaining Steps with Then and ThenAsync

Use Then when the next synchronous step can also fail. It runs only after an Ok result. If the current result is Err, the continuation is not called and the original OpError is carried forward.

OpResult SuspendUser(Guid userId)
{
    return ValidateUserId(userId)
        .Then(() => WriteAuditEntry(userId))
        .Then(() => MarkUserSuspended(userId));
}

OpResult ValidateUserId(Guid userId) =>
    userId == Guid.Empty
        ? OpResults.Err("User id is required.")
        : OpResults.Ok();

Then supports the common void/value transitions:

Current result Continuation Result
OpResult Func<OpResult> OpResult
OpResult Func<OpResult<T>> OpResult<T>
OpResult<T> Func<T, OpResult<TNext>> OpResult<TNext>
OpResult<T> Func<T, OpResult> OpResult
OpResult<User> LoadValidUser(Guid userId)
{
    return ValidateUserId(userId)
        .Then(() => LoadUser(userId));
}

OpResult<string> LoadDisplayName(Guid userId)
{
    return LoadUser(userId)
        .Then(user => LoadProfile(user.Id))
        .Then(profile => OpResults.Ok(profile.DisplayName));
}

OpResult SendWelcomeEmail(Guid userId)
{
    return LoadUser(userId)
        .Then(user => emailSender.SendWelcome(user.Email));
}

Use ThenAsync when the next step returns Task<OpResult> or Task<OpResult<T>>. You can call it on a direct result or on Task<OpResult*> to keep an async pipeline chained.

public Task<OpResult<string>> LoadDisplayNameAsync(Guid userId)
{
    return ValidateUserIdAsync(userId)
        .ThenAsync(() => LoadUserAsync(userId))
        .ThenAsync(user => LoadProfileAsync(user.Id))
        .ThenAsync(profile => LoadDisplayNameResultAsync(profile));
}

The first ThenAsync above receives a Task<OpResult>. The later calls receive Task<OpResult<T>>, so each continuation gets the successful value from the previous step. Every ThenAsync continuation returns Task<OpResult> or Task<OpResult<T>>, including the value-returning LoadDisplayNameResultAsync step:

Task<OpResult> ValidateUserIdAsync(Guid userId) =>
    validationService.ValidateUserIdAsync(userId);

Task<OpResult<string>> LoadDisplayNameResultAsync(UserProfile profile) =>
    profileStore.LoadDisplayNameResultAsync(profile);

Consuming Results with Match and MatchAsync

Use Match when the workflow is finished and both branches must be handled. Unlike Then, Match does not continue the business pipeline. It converts the result into a final value or consumes both branches through actions.

Fold a value result into another value:

string response = FindUser(userId).Match(
    onOk: user => $"Loaded {user.DisplayName}.",
    onErr: error => $"Could not load user: {error.Message}");

Fold a result without a success payload:

string status = SaveAuditLog(text).Match(
    onOk: () => "Audit log saved.",
    onErr: error => $"Audit log failed: {error.Message}");

Consume both branches through actions:

FindUser(userId).Match(
    onOk: user => logger.Info($"Loaded {user.Id}."),
    onErr: error => logger.Warn(error.Message));

If the result comes from an asynchronous pipeline but both branch handlers are synchronous, first await the pipeline and then call Match:

OpResult<User> result = await ValidateUserIdAsync(userId)
    .ThenAsync(() => LoadUserAsync(userId))
    .ThenAsync(user => EnsureActiveAsync(user));

string message = result.Match(
    onOk: user => $"Loaded {user.DisplayName}.",
    onErr: error => $"Could not load user: {error.Message}");

Use MatchAsync when branch handlers are asynchronous and return Task<TResult> or Task.

string message = await ValidateUserIdAsync(userId)
    .ThenAsync(() => LoadUserAsync(userId))
    .ThenAsync(user => EnsureActiveAsync(user))
    .MatchAsync(
        onOk: user => FormatLoadedUserAsync(user),
        onErr: error => FormatLoadErrorAsync(error));
Task<string> FormatLoadedUserAsync(User user) =>
    localization.FormatAsync("user.loaded", user.DisplayName);

Task<string> FormatLoadErrorAsync(OpError error) =>
    localization.FormatAsync("user.failed", error.Message);

MatchAsync can also consume asynchronous branch actions:

await LoadUserAsync(userId).MatchAsync(
    onOk: user => WriteLoadedAuditAsync(user),
    onErr: error => WriteFailedAuditAsync(error));
Task WriteLoadedAuditAsync(User user) =>
    audit.WriteAsync($"Loaded {user.Id}.");

Task WriteFailedAuditAsync(OpError error) =>
    audit.WriteAsync($"Failed: {error.Message}");

Running Side Effects with OnOk and OnErr

Use OnOk and OnErr for logging, metrics, auditing, and other side effects that should return the original result.

OpResult<User> loaded = LoadUser(userId)
    .OnOk(user => metrics.Increment("user.loaded"))
    .OnErr(error => logger.Warn(error.Message));

Asynchronous side effects use OnOkAsync and OnErrAsync. They also work directly on Task<OpResult> and Task<OpResult<T>>, so an async workflow can stay chained:

OpResult<User> loaded = await LoadUserAsync(userId)
    .OnOkAsync(user => audit.WriteAsync($"Loaded {user.Id}."))
    .OnErrAsync(error => audit.WriteAsync($"Load failed: {error.Message}"));

Wrapping Exception Boundaries with TryInvoke

Use TryInvoke and TryInvokeAsync at boundaries where exceptions should become Err results.

OpResult written = OpResults.TryInvoke(
    () => File.WriteAllText(path, text));

OpResult<User> loaded = OpResults.TryInvoke(
    () => legacyRepository.LoadUser(userId));

OpResult saved = await OpResults.TryInvokeAsync(
    () => repository.SaveAsync(user, cancellationToken));

OpResult<User> fetched = await OpResults.TryInvokeAsync(
    () => repository.LoadUserAsync(userId, cancellationToken));

TryInvoke uses zero-argument delegates. Pass arguments and cancellation tokens through lambdas or closures.

The boundary rules are:

  • A null delegate throws ArgumentNullException.
  • A non-cancellation exception becomes an Err whose message starts with the full exception type name.
  • Inner exceptions are preserved as an OpError.InnerError chain.
  • A null returned task or null returned payload becomes Err("Operation returned null.").
  • OperationCanceledException and derived cancellation exceptions propagate.

For example, an InvalidOperationException("outer failed", new ArgumentException("bad user id")) displays as:

System.InvalidOperationException: outer failed -> System.ArgumentException: bad user id

Result Boundaries

Successful payloads are non-null by design. OpResult<User?> and OpResults.Ok<User?>(null) are outside the supported success model.

default(OpResult) and default(OpResult<T>) are Err results with an empty error message.

Null, empty, and whitespace error messages are normalized to string.Empty:

OpResult result = OpResults.Err("   ");

Console.WriteLine(result.IsErr);          // True
Console.WriteLine(result.Error!.Message); // ""

Wrong-branch property reads are runtime fallbacks, not control-flow APIs:

  • Reading Value on an Err result returns default(T).
  • Reading Error on an Ok result returns an empty-message OpError.

Do not use Value != null, Error != null, or Error.Message == string.Empty to decide whether a result is Ok or Err. Use IsOk, IsErr, Then, or Match instead.

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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.
  • net6.0

    • No dependencies.
  • net8.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.3.0 168 6/26/2026
0.2.0 202 6/8/2026
0.1.1 120 6/4/2026
0.1.0 120 5/28/2026