Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

Clawfoot.ResultPattern

A Results pattern library for .NET: return success/failure and propagate errors across layers without throwing. Use Result and Result<T> instead of exceptions for expected failure paths.

Results are immutable. Every operation returns a new instance; chain with Invoke, WithError, WithValue, or combine with Result.Combine(...).

Null-safe. A successful Result<T> always carries a non-null value, and the library is fully annotated for nullable reference types: after a HasErrors/Success check the compiler knows Value is not null. No ! needed.

Target: .NET Standard 2.1. Upgrading from 3.x? See CHANGELOG.md.

Install

dotnet add package Clawfoot.ResultPattern

Quick usage

using Clawfoot.ResultPattern;

// Success (no value): a plain Result
Result result = Result.Ok();
if (!result) return result;

// Success with value: return the value, it converts implicitly
Result<User> FindUser(Guid id)
{
    User? user = _db.Find(id);
    if (user is null) return Result.Error("Not found", userMessage: "We couldn't find that user.");
    return user;
}

// Consuming: check, then use Value. The compiler knows it's not null.
Result<User> userResult = FindUser(id);
if (userResult.HasErrors)
    return userResult;              // or (Result)userResult to propagate into another Result<TOther>

Console.WriteLine(userResult.Value.Name);   // no '!'

Results and values

You have Use
Success, no value Result (Result.Ok())
Success with a value Result<T>: return the value, or Result.Ok(value)
Success where the value may legitimately be absent ("found nothing") (Result, T?) tuple, or a plain Result plus an out/nullable value
Failure Result.Error(...), which converts to any Result<T>

The rules, enforced by annotations, analyzers and at runtime:

  • A successful Result<T> always has a non-null Value. Creating one with null throws ArgumentNullException, and the compiler warns first ([DisallowNull]).
  • A failed result converts freely to any Result<T>, which is how errors propagate up the stack.
  • A successful plain Result cannot become a Result<T>, because there is no value to put in it. return Result.Ok(); in a Result<T> method is a compile error (CFRESULT003) and throws InvalidOperationException at runtime.

Reading the value

if (result.HasErrors) return result;   // or: if (!result.Success) / !result.IsOk / !result.HasResult
use(result.Value);

if (result.TryGetValue(out var value)) use(value);

// Tests and startup code: throws InvalidOperationException listing the errors
User user = CreateUser().GetValueOrThrow();
SaveSettings().EnsureSuccess();   // plain Result: throws the same way, does nothing on success

Assert.True(result.Success) and Assert.False(result.HasErrors) (xUnit) also tell the compiler the value is present. Fluent assertion chains like result.HasErrors.Should().BeFalse() don't, so use GetValueOrThrow() there. On a Result<T>, result.EnsureSuccess(); also tells the compiler result.Value is not null.

Immutable patterns

Assign the returned result to keep chaining or combining:

// Add an error → new result (a Result<T> keeps its value)
result = result.WithError("Validation failed");

// Combine multiple results → new result (errors combined; for Result<T>, last successful value wins)
Result combined = Result.Combine(result1, result2);
Result<int> combinedT = Result.Combine(r1, r2, r3);
Result<User> withValidation = Result.Combine(validationResult, userResult);

// Chain Invoke (returns new result on exception)
result = result.Invoke(() => ValidateInput(input)).Invoke(() => SaveToDb(entity));

// InvokeResult returns Result<T>
Result<int> r = start.InvokeResult(() => GetCount());

Error handling in a flow

Use Invoke / InvokeAsync to run code and capture exceptions into a new result (no mutation):

var result = Result.Ok()
    .Invoke(() => ValidateInput(input))
    .Invoke(() => SaveToDb(entity));

if (!result) return result;

// Async
var result = await Result.Ok()
    .InvokeAsync(async () => await FetchAndSaveAsync());

Error kinds

Every error can carry a kind: an enum value saying what sort of failure it is, so you can handle errors in one central switch. Use your own enum, the built-in ErrorKind, or both:

public enum AppErrorKind { NotFound, Conflict, PaymentDeclined }

return Result.Error("Card declined", AppErrorKind.PaymentDeclined);
return Result.Error<User>("User 42 not found", ErrorKind.NotFound);
result = result.WithError("Name is taken", AppErrorKind.Conflict);
var error = new Error("Invalid email", ErrorKind.UnprocessableEntity, memberName: "Email");

ErrorKind covers the HTTP 4xx and 5xx status codes, and each value is its status code ((int)ErrorKind.NotFound == 404). Errors created from exceptions (Result.Error(ex), WithException, Invoke) get ErrorKind.InternalServerError; pass a kind to override it: Result.Error(ex, ErrorKind.GatewayTimeout).

Handle them centrally:

IActionResult ToResponse(ResultBase result) => result.GetErrorKind() switch
{
    AppErrorKind.NotFound or ErrorKind.NotFound => NotFound(),
    AppErrorKind.Conflict                       => Conflict(),
    AppErrorKind.PaymentDeclined                => StatusCode(402),
    ErrorKind kind                              => StatusCode((int)kind),
    _                                           => StatusCode(500),   // null: no kind
};

A kind matches on its enum type as well as its value, so AppErrorKind.NotFound never matches ErrorKind.NotFound.

Decide by kind (or Code), never by message text: error.Message == "...", Message.Contains("not found") or switch (error.Message) change behaviour as soon as someone rewords a message. The analyzer CFRESULT006 warns about them.

On a result Returns
GetErrorKind() The kind of the first error that has one, or null
TryGetErrorKind<AppErrorKind>(out var kind) The first error kind of that enum type
HasErrorKind(AppErrorKind.Conflict) Whether any error has that kind

Code stays an app-specific number for the exact error (for docs and support), and GroupName stays a free-form label.

Custom errors

Implement IError with a strongly typed kind and expose it through the interface:

public sealed class AppError : IError
{
    public AppErrorKind Kind { get; init; }
    Enum? IError.Kind => Kind;
    // ...Code, Message, etc.
}

Existing IError implementations keep compiling: Kind defaults to null.

JSON (System.Text.Json)

Kinds serialize as "TypeName.Member", e.g. {"Message":"Card declined","Kind":"AppErrorKind.PaymentDeclined",...}. Writing needs no setup. To read your own kinds back, register their enum types once (ErrorKind is always known):

options.Converters.Add(new ErrorKindJsonConverter(typeof(AppErrorKind)));

Reading a kind that isn't registered, or a member the enum doesn't have, throws JsonException, so a missing registration fails on first use instead of silently losing the kind. A client that may receive kinds added by a newer server can read them as null instead: new ErrorKindJsonConverter(ignoreUnknownKinds: true, typeof(AppErrorKind)). The registered converter also handles Enum-typed kind properties on custom IError types.

Requiring a kind on every error

Kinds are optional. To require one everywhere, turn on the opt-in analyzer CFRESULT005 (see Analyzers).

Enum-based errors

Decorate an enum with [Error] and create results from it:

public enum UserErrors
{
    [Error(Code = 1001, Kind = ErrorKind.NotFound, Message = "User not found", UserMessage = "We couldn't find that user.")]
    NotFound,
    [Error(Code = 1002, Kind = ErrorKind.BadRequest, Message = "Invalid id")]
    InvalidId
}

Result r = Result.FromError(UserErrors.NotFound);   // r.GetErrorKind() == ErrorKind.NotFound

Kind is copied onto the error; without it, the error has no kind. It must be an enum value: Kind = 404 or Kind = "NotFound" compiles (attribute properties can't be typed Enum) but is a build error (CFRESULT007).

Analyzers

The package includes Roslyn analyzers. They load automatically, with no extra package to install, in every project that references the package, directly or through another project or package (e.g. a test project referencing your service project).

ID Default Flags
CFRESULT001 Warning Discarded result of With*, Invoke, InvokeAsync, Combine (results are immutable, so the call does nothing)
CFRESULT002 Warning result.Value!. Check HasErrors/Success instead, or use GetValueOrThrow() in tests
CFRESULT003 Error A known-successful Result (Result.Ok(), new Result()) converted to Result<T> (implicitly, by cast, or with As<T>())
CFRESULT004 Warning Result<X?>: a nullable type argument can't represent "no value"; use (Result, X?)
CFRESULT005 Off (opt-in) An error created without a kind: new Error(...), Result.Error(message), WithError(message), WithErrorIf* without a kind, a null kind, [Error] on an enum member without Kind, or an IError implementation without a Kind property
CFRESULT006 Warning A decision made on error text: IError.Message, UserMessage, ToString(), ToUserString() or a result's ToString()/ToUserFriendlyString() compared with ==/!=, Equals, Contains, StartsWith, EndsWith, IndexOf, Regex.IsMatch, or matched in a switch or is pattern. Logging, formatting and displaying the text is fine
CFRESULT007 Error [Error(Kind = ...)] set to something other than an enum value, e.g. Kind = 404 (Error.From would throw)

Raise or lower severities in .editorconfig:

[*.cs]
dotnet_diagnostic.CFRESULT002.severity = error
dotnet_diagnostic.CFRESULT004.severity = error
dotnet_diagnostic.CFRESULT005.severity = warning   # opt in: require a kind on every error

Put these settings in an .editorconfig that covers every project, usually at the repository root. One under src/ doesn't apply to projects under tests/, so the rules keep their defaults there.

Features

  • Result and Result: immutable; Success, IsOk, HasErrors, HasResult, Errors, Message, Value, TryGetValue, GetValueOrThrow, EnsureSuccess
  • Nullable flow: HasErrors/Success/IsOk/HasResult/TryGetValue/EnsureSuccess tell the compiler when Value is non-null
  • Result.Combine(...): combines multiple results (errors combined; for Result<T>, last successful value wins)
  • WithError / WithErrors / WithException / WithValue: return a new result with added state
  • Invoke / InvokeAsync / InvokeResult: wrap calls and return a new result (chainable)
  • Error and IError: structured errors (Kind, Code, Message, UserMessage, GroupName, MemberName)
  • Error kinds: any enum as IError.Kind, a built-in HTTP-aligned ErrorKind, GetErrorKind / TryGetErrorKind / HasErrorKind, and System.Text.Json support
  • ErrorAttribute and Error.From(enum) for enum-driven error messages
  • Implicit conversion of Result to bool (if (!result) ...)

License

LGPL-3.0-only. © 2026 Douglas Gaskell / Clawfoot Software

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages