Generic Result pattern and functional primitives for .NET - zero dependencies, zero business logic. Provides railway-oriented programming extensions, error types, and pattern matching support.
$ dotnet add package Clywell.PrimitivesGeneric Result pattern and functional primitives for .NET. Provides railway-oriented programming extensions, typed error handling, and pattern matching support with zero dependencies and zero business logic — can be used in any .NET application.
Error, ErrorCode, ValidationError, ValidationFailure with metadata, inner errors, and field-level detailsMap, Bind, Match, Tap, Ensure, MapError, TapError for composable pipelinesResult<T> / ResultTask<Result<T>> and Task<Result> compositionMatch (with return) and Switch (side-effects) for exhaustive handlingCollect to aggregate IEnumerable<Result<T>> and Combine for tuplesResult.Try() / Result.TryAsync() to convert exception-based code to ResultsResult.FromNullable() for reference types and nullable value typesDeconstruct for tuple-style consumption, ToResult() / Map<T> / Bind<T> to bridge between Result and Result<T>dotnet add package Clywell.Primitives
using Clywell.Primitives;
// ── Value-returning operations: Result<T> ──────────────────────
// Implicit conversions — no factory needed
Result<int> success = 42;
Result<int> failure = Error.NotFound("User not found.");
// Factory methods
var ok = Result.Success(42);
var fail = Result.Failure<int>(Error.Conflict("Already exists"));
// ── Void-like operations: Result ───────────────────────────────
Result deleted = Result.Success();
Result notFound = Result.Failure(Error.NotFound("Item not found"));
Result fromError = Error.Forbidden("Not allowed"); // implicit conversion
// ── Pattern matching ───────────────────────────────────────────
string message = ok.Match(
onSuccess: value => $"Got: {value}",
onFailure: error => $"Failed: {error.Description}");
deleted.Switch(
onSuccess: () => Console.WriteLine("Done"),
onFailure: error => Console.WriteLine($"Error: {error}"));
// ── Railway-oriented pipeline ──────────────────────────────────
var result = Result.Success("42")
.Map(int.Parse) // string → int
.Ensure(v => v > 0, Error.Failure("Must be > 0")) // validate
.Bind(v => LookupUser(v)) // int → Result<User>
.Tap(user => Console.WriteLine(user.Name)) // side effect
.MapError(e => Error.Unexpected(e.Description)) // remap error
.Map(user => user.Email); // User → string
ErrorCodeA readonly record struct classifying error categories. Supports implicit conversion to/from string for extensibility.
| Code | Value | Usage |
|---|---|---|
ErrorCode.Failure | "General.Failure" | General/unspecified failure |
ErrorCode.Validation | "General.Validation" | Validation rule violations |
ErrorCode.NotFound | "General.NotFound" | Resource not found |
ErrorCode.Conflict | "General.Conflict" | Duplicate/conflict scenarios |
ErrorCode.Unauthorized | "General.Unauthorized" | Authentication failures |
ErrorCode.Forbidden | "General.Forbidden" | Authorization/permission failures |
ErrorCode.Unexpected | "General.Unexpected" | Internal/unexpected errors |
ErrorCode.Unavailable | "General.Unavailable" | Service/resource unavailable |
// Custom error codes via implicit conversion
ErrorCode custom = "Billing.PaymentDeclined";
// String comparison
string code = ErrorCode.NotFound; // "General.NotFound"
ErrorA record with Code, Description, optional InnerError, and Metadata. Immutable — builder methods return new instances.
Error.Failure("Something went wrong")
Error.NotFound("User not found")
Error.Conflict("Email already registered")
Error.Unauthorized("Invalid credentials")
Error.Forbidden("Insufficient permissions")
Error.Unexpected("Unhandled exception occurred")
Error.Unavailable("Service temporarily down")
Error.Validation("Email", "Email is required") // single field
Error.Validation( // multiple fields
new ValidationFailure("Email", "Required"),
new ValidationFailure("Age", "Must be ≥ 18"))
var error = Error.NotFound("Order not found")
.WithMetadata("OrderId", orderId)
.WithMetadata("RequestId", correlationId)
.WithInnerError(originalError);
// Bulk metadata
var enriched = error.WithMetadata(new Dictionary<string, object>
{
["Timestamp"] = DateTime.UtcNow,
["Retry"] = 3
});
| Property | Type | Description |
|---|---|---|
Code | ErrorCode | Categorized error code |
Description | string | Human-readable error message |
InnerError | Error? | Optional causal error (error chain) |
Metadata | ImmutableDictionary<string,object> | Key-value pairs for context |
ValidationFailureA readonly record struct representing a single field-level validation failure.
var failure = new ValidationFailure("Email", "Email is required");
failure.FieldName; // "Email"
failure.Message; // "Email is required"
failure.ToString(); // "Email: Email is required"
ValidationErrorA sealed record extending Error with structured validation details. Always has ErrorCode.Validation.
var error = Error.Validation(
new ValidationFailure("Email", "Required"),
new ValidationFailure("Name", "Too long"));
error.Failures; // ImmutableArray<ValidationFailure>
error.FailureCount; // 2
error.HasFailureForField("Email"); // true
error.GetFailuresForField("Email"); // IEnumerable<ValidationFailure>
// Append failures (returns new instance)
var combined = error.AddFailures(
new ValidationFailure("Age", "Must be positive"));
Result<T> — Value-Returning OperationsA readonly struct representing success with a TValue or failure with an Error. Implicit conversions allow assigning values and errors directly.
// Implicit conversions
Result<User> success = user;
Result<User> failure = Error.NotFound("User not found");
// Factory methods
Result.Success(user);
Result.Failure<User>(error);
Result.Failure<User>("Something went wrong"); // shorthand for Error.Failure(...)
| Property | Type | Description |
|---|---|---|
IsSuccess | bool | true if the result contains a value |
IsFailure | bool | true if the result contains an error |
Value | T | The success value (throws if failure) |
Error | Error | The error (throws if success) |
| Method | Returns | Description |
|---|---|---|
Match(onSuccess, onFailure) | TOut | Pattern match with return value |
Switch(onSuccess, onFailure) | void | Pattern match with side effects |
Map(fn) | Result<TOut> | Transform success value |
Bind(fn) | Result<TOut> | Chain result-producing function (flatMap) |
Tap(action) | Result<T> | Side effect on success |
OnSuccess(action) | Result<T> | Execute action on success |
OnFailure(action) | Result<T> | Execute action on failure |
ValueOr(fallback) | T | Get value or fallback |
ValueOr(fn) | T | Get value or compute fallback from error |
ToResult() | Result | Discard value, preserve success/failure |
Deconstruct | (bool, T?, Error?) | Tuple-style: var (ok, val, err) = result |
MapAsync(fn) | Task<Result<TOut>> | Transform with async function |
BindAsync(fn) | Task<Result<TOut>> | Chain with async result-producing function |
TapAsync(fn) | Task<Result<T>> | Async side effect on success |
TapErrorAsync(fn) | Task<Result<T>> | Async side effect on failure |
MatchAsync(onSuccess, onFailure) | Task<TOut> | Pattern match with async functions |
| Method | Returns | Description |
|---|---|---|
Ensure(predicate, error) | Result<T> | Validate with predicate |
Ensure(predicate, errorFactory) | Result<T> | Validate with lazy error from value |
MapError(fn) | Result<T> | Transform the error |
TapError(action) | Result<T> | Side effect on failure |
Task<Result<T>>)These allow chaining directly off async operations without await:
| Method | Returns | Description |
|---|---|---|
.Map(fn) | Task<Result<TOut>> | Transform success value |
.Bind(fn) | Task<Result<TOut>> | Chain sync result-producing function |
.BindAsync(fn) | Task<Result<TOut>> | Chain async result-producing function |
.Match(onSuccess, onFailure) | Task<TOut> | Pattern match |
.Ensure(predicate, error) | Task<Result<T>> | Validate |
.Ensure(pred, errorFactory) | Task<Result<T>> | Validate with lazy error from value |
.Tap(action) | Task<Result<T>> | Side effect on success |
.TapError(action) | Task<Result<T>> | Side effect on failure |
var email = await GetUserAsync(id) // Task<Result<User>>
.Ensure(u => u.IsActive, Error.Failure("Inactive"))
.Map(u => u.Email)
.TapError(e => logger.LogWarning("Failed: {Error}", e));
Result — Void-Like OperationsA readonly struct for operations that succeed or fail but carry no value (e.g., Delete, SendEmail).
Result success = Result.Success();
Result failure = Result.Failure(Error.NotFound("Item not found"));
Result quick = Result.Failure("Something went wrong");
Result fromErr = Error.Forbidden("Not allowed"); // implicit conversion
| Property | Type | Description |
|---|---|---|
IsSuccess | bool | true if the operation succeeded |
IsFailure | bool | true if the operation failed |
Error | Error | The error (throws if success) |
| Method | Returns | Description |
|---|---|---|
Match(onSuccess, onFailure) | TOut | Pattern match with return value |
Switch(onSuccess, onFailure) | void | Pattern match with side effects |
Tap(action) | Result | Side effect on success |
OnSuccess(action) | Result | Execute action on success |
OnFailure(action) | Result | Execute action on failure |
Map<T>(fn) | Result<T> | Bridge to Result on success |
Bind<T>(fn) | Result<T> | Chain to Result via binder |
Deconstruct | (bool, Error?) | Tuple-style: var (ok, err) = result |
TapAsync(fn) | Task<Result> | Async side effect on success |
TapErrorAsync(fn) | Task<Result> | Async side effect on failure |
MatchAsync(onSuccess, onFailure) | Task<TOut> | Pattern match with async functions |
| Method | Returns | Description |
|---|---|---|
Ensure(predicate, error) | Result | Validate with predicate |
MapError(fn) | Result | Transform the error |
TapError(action) | Result | Side effect on failure |
Task<Result>)| Method | Returns | Description |
|---|---|---|
.Match(onSuccess, onFailure) | Task<TOut> | Pattern match |
.Ensure(predicate, error) | Task<Result> | Validate with predicate |
.Map<T>(fn) | Task<Result<T>> | Bridge to Result on success |
.Bind<T>(fn) | Task<Result<T>> | Chain to Result via binder |
.Tap(action) | Task<Result> | Side effect on success |
.TapError(action) | Task<Result> | Side effect on failure |
await Result.TryAsync(() => emailService.SendAsync(message))
.Tap(() => logger.LogInformation("Email sent"))
.TapError(e => logger.LogError("Send failed: {Error}", e));
All accessible via Result.*:
| Method | Returns | Description |
|---|---|---|
Result.Success() | Result | Non-generic success |
Result.Failure(error) | Result | Non-generic failure from error |
Result.Failure(description) | Result | Non-generic failure from string |
Result.Success<T>(value) | Result<T> | Generic success |
Result.Failure<T>(error) | Result<T> | Generic failure from error |
Result.Failure<T>(description) | Result<T> | Generic failure from string |
Result.Try(action) | Result | Wrap void action in try/catch |
Result.Try<T>(func) | Result<T> | Wrap function in try/catch |
Result.TryAsync(func) | Task<Result> | Wrap async action in try/catch |
Result.TryAsync<T>(func) | Task<Result<T>> | Wrap async function in try/catch |
Result.FromNullable<T>(value, msg) | Result<T> | Null → NotFound failure (reference types) |
Result.FromNullable<T>(value?, msg) | Result<T> | Null → NotFound failure (nullable value types) |
Result.Combine(r1, r2) | Result<(T1, T2)> | Combine 2 results into tuple |
Result.Combine(r1, r2, r3) | Result<(T1,T2,T3)> | Combine 3 results into tuple |
Result.Combine(r1, r2, r3, r4) | Result<(T1,T2,T3,T4)> | Combine 4 results into tuple |
Result.Combine(r1, r2, r3, r4, r5) | Result<(T1,...,T5)> | Combine 5 results into tuple |
| Method | Returns | Description |
|---|---|---|
results.Collect() | Result<IReadOnlyList<T>> | Aggregate sequence; fail on first error |
public Result<User> CreateUser(CreateUserRequest request)
{
return ValidateRequest(request)
.Bind(req => CheckDuplicateEmail(req.Email))
.Map(email => new User(request.Name, email))
.Bind(user => repository.Save(user))
.Tap(user => eventBus.Publish(new UserCreatedEvent(user.Id)));
}
public async Task<Result> DeleteOrderAsync(int orderId)
{
return await FindOrder(orderId)
.Map(order => order.Id)
.BindAsync(id => repository.DeleteAsync(id))
.Tap(() => logger.LogInformation("Deleted order {Id}", orderId))
.TapError(e => logger.LogWarning("Delete failed: {Error}", e));
}
[HttpPost]
public IActionResult Create(CreateUserRequest request)
{
return userService.CreateUser(request).Match(
onSuccess: user => CreatedAtAction(nameof(Get), new { id = user.Id }, user),
onFailure: error => error.Code.Value switch
{
"General.Validation" => BadRequest(error),
"General.Conflict" => Conflict(error),
"General.NotFound" => NotFound(error),
_ => StatusCode(500, error)
});
}
var combined = Result.Combine(
GetUser(userId),
GetOrder(orderId),
GetPayment(paymentId));
return combined.Map(tuple =>
{
var (user, order, payment) = tuple;
return new OrderSummary(user.Name, order.Total, payment.Status);
});
// 4 & 5 result overloads available too
var all = Result.Combine(name, email, age, address);
var full = Result.Combine(name, email, age, address, phone);
// Non-generic Result
var (ok, error) = Result.Success();
// ok = true, error = null
// Generic Result<T>
var (isSuccess, value, err) = Result.Success(42);
// isSuccess = true, value = 42, err = null
// Result → Result<T> via Map/Bind
Result deleted = DeleteOrder(id);
Result<string> message = deleted.Map(() => "Order deleted");
Result<Order> order = deleted.Bind(() => LoadOrder(id));
// Result<T> → Result via ToResult (discards value)
Result<User> userResult = GetUser(id);
Result plain = userResult.ToResult();
var error = Error.NotFound("Order not found")
.WithMetadata("OrderId", orderId)
.WithInnerError(originalError);
// Validation with rich details
var validation = Error.Validation(
new ValidationFailure("Email", "Required"),
new ValidationFailure("Age", "Must be 18 or older"));
validation.Failures; // ImmutableArray<ValidationFailure>
validation.HasFailureForField("Email"); // true
// Combine validation errors
var merged = validation.AddFailures(
new ValidationFailure("Name", "Too long"));
var results = ids.Select(id => ParseId(id)); // IEnumerable<Result<int>>
Result<IReadOnlyList<int>> all = results.Collect();
// Success with all values, or first error encountered
// Sync — value-returning
var parsed = Result.Try(() => int.Parse(userInput));
// Sync — void
var written = Result.Try(() => File.WriteAllText(path, content));
// Async — value-returning
var data = await Result.TryAsync(() => httpClient.GetStringAsync(url));
// Async — void
var sent = await Result.TryAsync(() => emailService.SendAsync(msg));
// Reference type
Result<User> user = Result.FromNullable(
repository.FindById(id),
"User not found");
// Nullable value type
Result<int> count = Result.FromNullable(
GetOptionalCount(), // int?
"Count unavailable");
git checkout -b feature/my-featuregit commit -m 'feat: add my feature'git push origin feature/my-featureFollow Conventional Commits:
feat: — New featurefix: — Bug fixtest: — Test additions/changesdocs: — Documentation onlychore: — Build/tooling changesMIT © 2026 Clywell