Vyrn.Results
0.1.0
dotnet add package Vyrn.Results --version 0.1.0
NuGet\Install-Package Vyrn.Results -Version 0.1.0
<PackageReference Include="Vyrn.Results" Version="0.1.0" />
<PackageVersion Include="Vyrn.Results" Version="0.1.0" />
<PackageReference Include="Vyrn.Results" />
paket add Vyrn.Results --version 0.1.0
#r "nuget: Vyrn.Results, 0.1.0"
#:package Vyrn.Results@0.1.0
#addin nuget:?package=Vyrn.Results&version=0.1.0
#tool nuget:?package=Vyrn.Results&version=0.1.0
Vyrn.Results
Result pattern primitives for explicit success/failure flows — errors are values you return, not exceptions you throw. The library is designed so call sites read as naturally as (or better than) try/catch: one error factory works everywhere, pipelines compose without ceremony, and exceptions are captured at the boundary without losing diagnostics.
See docs/TECHNICAL.md for how everything works under the hood.
Install
dotnet add package Vyrn.Results
Requires .NET 10.
The 60-second tour
using Vyrn.Results;
// 1. Define your component's errors once, as a static class backed by an ErrorCatalog.
public static class OrderErrors
{
private static readonly ErrorCatalog Catalog = new("Shop.Orders");
public static ResultFailure NotFound(string id) => Catalog.ResourceNotFound("Order", id);
public static ResultFailure GetFailed() => Catalog.Failure("GetFailed", "Failed to fetch order.");
}
// 2. Wrap throwing boundaries with Result.Try; return errors as values.
public Task<Result<OrderDto>> GetOrder(string id, CancellationToken ct)
=> Result.Try(
async () =>
{
var response = await httpClient.GetAsync($"/api/orders/{id}", ct);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return OrderErrors.NotFound(id); // ResultFailure -> Result<OrderDto>
}
var dto = await response.Content.ReadFromJsonAsync<OrderDto>(ct);
return Result.Success(dto!);
},
OrderErrors.GetFailed()); // thrown exception -> this failure, exception attached
// 3. Compose without unwrapping.
public Task<Result<OrderResponse>> GetEnriched(string id, CancellationToken ct)
=> GetOrder(id, ct)
.Bind(dto => LookupCustomer(dto, ct))
.Map(customer => ToResponse(customer));
// 4. Collapse exactly once, at the edge.
var result = await service.GetEnriched(id, ct);
return result.ToActionResult(); // your app's mapping, e.g. to ProblemDetails
Core concepts
| Type | Role |
|---|---|
Result / Result<T> |
The outcome: success (optionally with a value) or failure with an Error. |
Error |
Stable Code, human Description, ErrorType (drives HTTP mapping), optional diagnostic Exception. |
ResultFailure |
The error currency: a failure that is not yet a result. Implicitly converts to both Result and Result<T>. |
ValidationError |
An Error aggregating multiple detail errors, so validation reports everything at once. |
ErrorCatalog |
Mints component-scoped failures with a shared code prefix. |
Errors |
Conventional factories: Required, NotFound, AlreadyExists. |
The single most important idea is ResultFailure. Because your error factories return it, one
call-site shape works for every method — the compiler converts it to whichever result type is
needed. You never write Result.Failure<SomeLongType>(...) in application code.
Usage patterns
Boundaries: Result.Try
Wrap code that can throw (HTTP clients, serialization, drivers). Sync and async overloads exist:
Result.Try(async () => { ... }, OrderErrors.GetFailed());
- The caught exception is attached to the returned error (
Error.Exception) — log it at your transport boundary, once, instead of in every method. OperationCanceledExceptionis never swallowed; cancellation still flows.- Need per-exception handling? Use the lambda overload:
Result.Try(action, ex => ...). - Type inference works even when the lambda mixes
ResultFailureandResult.Success(x)returns — no explicit type argument needed.
Pipelines: Map, Bind, Ensure, Tap, Match
All exist on Result, Result<T>, and as extensions on Task<Result> / Task<Result<T>>,
so async chains don't need intermediate awaits:
return inventoryClient.GetItems(ids, ct) // Task<Result<List<Item>>>
.Ensure(items => items.Count > 0, InventoryErrors.Empty())
.Bind(items => orderClient.Create(items, ct))
.Tap(created => cache.Store(created))
.TapFailure(error => metrics.CountFailure(error.Code))
.Map(created => ToResponse(created));
Map— transform the value (or produce one from a plainResult).Bind— chain another result-returning operation; failures short-circuit.Ensure— turn a success into a failure when a predicate rejects the value.Tap/TapFailure— side effects that leave the result untouched.Match— collapse to a final value with exactly one of two branches.
Guards and validation
var validation = Result.Validate(
Result.Ensure(!string.IsNullOrEmpty(request.Title), OrderErrors.TitleRequired()),
Result.Ensure(request.DeliveryDate > now, OrderErrors.DeliveryDateInPast()));
if (validation.IsFailure)
{
return validation.AsFailure<OrderDto>();
}
Validate succeeds when all checks pass, returns a single failure unwrapped, and aggregates
multiple failures into a ValidationError whose Details list every problem — so API consumers
can fix all fields in one round trip.
Imperative access
Not everything wants to be a pipeline. Use TryGetValue or the properties:
if (!result.TryGetValue(out var order))
{
return result.AsFailure<Response>(); // re-type the failure across value types
}
// Rust-style unwrapping:
var value = result.Unwrap(); // Value; throws on failure
var orDefault = result.UnwrapOr(fallback); // value or fallback
var orComputed = result.UnwrapOrElse(e => Fix(e)); // value or produced from the error
// or: result.IsSuccess / result.IsFailure / result.Error / result.Value
Value / Unwrap() on a failure throw InvalidOperationException by design — there is no
silent default.
The transport edge
Map errors to your wire format in one place, keyed on ErrorType
(Validation → 400, NotFound → 404, Conflict → 409, Failure → 500). Recommended shape is
RFC 7807 ProblemDetails with the Code in extensions. That boundary is also the right single
place to log Error.Exception. Never serialize Error itself — it's an internal type and
carries the exception.
Gotchas
- The implicit
T -> Result<T>conversion takes a non-nullableT, so returning a possibly-null value through it is a nullability warning (an error underTreatWarningsAsErrors) — decide at the call site what null means and return an explicit component error. If a null slips past the annotations anyway, the runtime net converts it to a context-freeGeneral.Nullfailure rather than a success carrying null. Errorequality ignores the attachedException— two errors with the same code, description and type are equal. This is what makes tests and deduplication sane.- A lambda that only throws (
() => throw ...) is ambiguous between the sync and asyncTryoverloads; give it anasyncmodifier or an explicit return type. Real bodies are unaffected.
License
MIT — see LICENSE.
| 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
- 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 |
|---|