Persiltech.Results
1.0.2
dotnet add package Persiltech.Results --version 1.0.2
NuGet\Install-Package Persiltech.Results -Version 1.0.2
<PackageReference Include="Persiltech.Results" Version="1.0.2" />
<PackageVersion Include="Persiltech.Results" Version="1.0.2" />
<PackageReference Include="Persiltech.Results" />
paket add Persiltech.Results --version 1.0.2
#r "nuget: Persiltech.Results, 1.0.2"
#:package Persiltech.Results@1.0.2
#addin nuget:?package=Persiltech.Results&version=1.0.2
#tool nuget:?package=Persiltech.Results&version=1.0.2
Persiltech.Results
An implementation of the Result pattern: an operation returns its success or its failure as a value, with localized error messages, instead of throwing for outcomes you already expect.
Installation
dotnet add package Persiltech.Results
The contract
There are three shapes of result, depending on what the operation has to return.
namespace Persiltech.Results;
// No value. Only whether it worked, and why not.
public sealed class Result : ResultBase
{
public static Result Success();
public static Result Fail(params Error[] errors);
public static Result Fail(string errorMessage);
}
// A value on the success side only.
public sealed class Result<TSuccess> : ResultBase
{
public TSuccess Value { get; }
public static Result<TSuccess> Success(TSuccess value);
public static Result<TSuccess> Fail(params Error[] errors);
public static Result<TSuccess> Fail(string errorMessage);
}
// A value of its own on each side. This is the railway one.
public sealed class Result<TSuccess, TError>
{
public TSuccess Value { get; } // throws when the result is a failure
public TError Error { get; } // throws when the result is a success
public bool IsSuccess { get; }
public bool IsFailure { get; }
}
The first two share ResultBase, which carries the failures:
public class ResultBase
{
public Error[] Errors { get; }
public Error? Error { get; } // the first one, or null
public string? ErrorMessage { get; } // its message, or null
public bool IsFailure { get; }
public bool IsSuccess { get; }
}
public class Error(string? code, string message)
{
public string? Code { get; }
public string Message { get; }
}
Code is worth setting when the caller has to decide on the failure: it stays stable while
Message may be translated.
Results are never built with
new. They come fromSuccessorFail, so a result is always born in one of the two states and never half-way.
Usage
Returning a result
using Persiltech.Results;
public Result<Customer> FindCustomer(int id)
{
var customer = repository.Find(id);
return customer is null
? Result<Customer>.Fail(new Error("customer.not-found", "No such customer."))
: Result<Customer>.Success(customer);
}
Reading it without tripping up
Use Match: it makes you handle both branches, so there is no way to read the wrong side.
using Persiltech.Results.Extensions;
string message = FindCustomer(id).Match(
onSuccess: customer => $"Found {customer.Name}.",
onError: result => result.ErrorMessage ?? "Unknown failure.");
Result<TSuccess>.Valueis not guarded. On a failed result it returns the default value ofTSuccessrather than throwing, so checkIsSuccessfirst — or useMatch, which is why these extensions exist.
Chaining without checks in between
Result<TSuccess, TError> is the railway variant: each step runs only if the previous one
succeeded, and the first failure short-circuits the rest.
Result<Invoice, Error> result = FindOrder(orderId)
.Bind(order => ValidateStock(order))
.Map(order => BuildInvoice(order))
.OnSuccess(invoice => logger.LogInformation("Invoice {Id} issued", invoice.Id))
.OnFailure(error => logger.LogWarning("Could not issue: {Message}", error.Message));
| Method | What it does |
|---|---|
Map(mapper) |
Transforms the success value. A failure passes through untouched |
MapError(mapper) |
Transforms the error. A success passes through untouched |
Bind(binder) |
Chains another operation that also returns a Result |
BindAsync(binder) |
The asynchronous version of Bind |
OnSuccess(action) |
Runs a side effect on success and returns the same result |
OnFailure(action) |
Runs a side effect on failure and returns the same result |
Match(onSuccess, onError) |
Collapses both branches into one value, or into an action |
Because it defines implicit conversions, you can return a value or an error directly:
public Result<Customer, Error> FindCustomer(int id)
{
var customer = repository.Find(id);
if (customer is null)
{
return new Error("customer.not-found", "No such customer.");
}
return customer;
}
With
TSuccessandTErrorof the same type the conversion would be ambiguous. UseSuccessandFailexplicitly there.
Design decisions
- Results are created only through
SuccessandFail, never withnew. Result<TSuccess, TError>throws when you read the wrong branch, with a localized message fromPersiltech.Localizer. Reading the value of a failed result is a programming mistake, not a case worth representing.Map,Bind,OnSuccessandOnFailureshort-circuit, so a chain needs no intermediate checks from the caller.- The extensions give the three shapes the same
Match, so they are consumed alike.
Out of scope
- Dependency injection registration: there is nothing to register.
- Converting exceptions into results, or the other way round. That is the consumer's call.
- Aggregating results of several operations into one.
Compatibility
net10.0
Version history
The source lives in the monorepo; this table summarises what each published version changed.
| Version | Changes |
|---|---|
| 1.0.2 | The .nuspec now declares the repository, which is public, and SourceLink is on, so consumers can step into the source while debugging. The README links to the monorepo and support moves to GitHub issues. The floor for Persiltech.Localizer moves from 1.0.1 to 1.0.3. No change to the public surface. |
| 1.0.1 | The project website now points to the portfolio page where the package is documented. The real licence text ships inside the .nupkg instead of an SPDX expression. The public surface is documented with XML comments. This README is written from scratch: the previous one was three lines and named a package that does not exist. No change to the public API. |
| 1.0.0 | First release of Result, Result<TSuccess> and Result<TSuccess, TError>. |
This package supersedes Persiltech.Result (singular), which is no longer maintained.
Support
For questions, bug reports or feature requests open an issue. You can also see the package page.
Support the development
If this package saves you work, you can support its maintenance on GitHub Sponsors.
| Product | Versions 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. |
-
net10.0
- Persiltech.Localizer (>= 1.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v1.0.2
- The .nuspec now declares the repository, which is public, and SourceLink is on: consumers can step into the source while debugging
- The README links to the monorepo and support moves to GitHub issues
- The floor for Persiltech.Localizer moves from 1.0.1 to 1.0.3
- No change to the code or to the public surface
v1.0.1
- The project website now points to the portfolio page, where the package is documented
- The real licence text ships inside the .nupkg instead of an SPDX expression
- The public surface is documented with XML comments, so IntelliSense works for consumers