Table of Contents

Error Handling

Error is a closed discriminated union of typed records that lets you return failures as values and pattern-match them at the boundary, without falling back to exceptions for normal control flow.

Patterns Index

Goal Use See
Return a typed failure from a function Result.Fail<T>(new Error.X(payload) { Detail = "..." }) Creating errors
Fail but still persist staged work (worker handler) Result.FailAfterCommit<T>(error) Persisting failure state from a worker handler
Build a single-violation 422 from a property name Error.InvalidInput.ForField(field: "email", code: ValidationCodes.ValueNotEmpty, detail: "...") Validation failures
Require nullable values without !, preserving typed composition Result.EnsureNotNull(value, "field", detail) / task.EnsureNotNullAsync(...) Required values
Build a single-violation 422 from an object-level rule Error.InvalidInput.ForRule(code: "password.confirmation-mismatch", detail: "...") Validation failures
Aggregate per-field and cross-field violations new Error.InvalidInput(Fields: ..., Rules: ...) Validation failures
Branch on the closed catalog at a boundary result.Match(value => ..., error => error switch { Error.NotFound nf => ..., ... }) Pattern matching
Log without changing the result result.TapOnFailure(error => logger.LogWarning(...)) Propagating errors
Translate one failure case into another result.MapOnFailure(error => error switch { ... }) Propagating errors
Capture a thrown exception as a typed error Result.Try(() => ..., ex => new Error.X(...)) Capturing exceptions
Merge multiple failures into one Result.Combine(r1, r2, r3) or left.Combine(right) on Error Composition
Map an Error to an HTTP / Problem Details response result.ToHttpResponse(...) (in Trellis.Asp) Boundary mapping

Use this guide when

  • You want expected failures (validation, not found, conflict, forbidden) to be values your callers can branch on, not exceptions.
  • You need compile-time exhaustiveness across every failure case at a boundary.
  • You are aggregating multiple validation failures and want to keep them all instead of throwing the first one away.
  • You need a stable wire vocabulary (Kind, Code, payload) that survives refactors and renders consistently as Problem Details.

Surface at a glance

Error is an abstract record with 12 nested sealed record cases. The base type has a private constructor, so the catalog is closed and switch over an Error reference is exhaustive at the language level.

Category Cases
Input validation InvalidInput, InvariantViolation
Resource access NotFound, Gone, Forbidden, AuthenticationRequired
State Conflict
Availability Unavailable, RateLimited
Unexpected Unexpected
Transport envelope TransportFault
Composition Aggregate

Every case carries a strongly-typed payload, an init-only Detail property, and a structured (never Exception) Cause chain. Full per-case constructor signatures, payload notes, and the supporting types (ResourceRef, InputPointer, FieldViolation, RuleViolation, RetryAdvice, ITransportFault, EquatableArray<T>) live in trellis-api-core.md → Error.

Note

Unexpected(code, faultId?) covers both unhandled faults (set FaultId to correlate with telemetry) and internal invariant violations ("shouldn't happen"). The optional FaultId surfaces as a faultId problem-details extension at the ASP boundary when set. Use the Code == FaultCodes.NotImplemented convention if you need the boundary to map to HTTP 501; the default maps to 500.

Installation

dotnet add package Trellis.Core

Quick start

Return a typed failure, then pattern-match at the boundary.

using Trellis;

public sealed record User(string Id, string Email);

public static class UserService
{
    public static Result<User> GetUser(string id) =>
        id == "42"
            ? Result.Ok(new User("42", "ada@example.com"))
            : Result.Fail<User>(new Error.NotFound(ResourceRef.For<User>(id)) { Detail = $"User {id} not found" });
}

var message = UserService.GetUser("42").Match(
    onSuccess: user  => $"200 OK: {user.Email}",
    onFailure: error => error switch
    {
        Error.NotFound nf  => $"404 Not Found: {nf.Detail}",
        Error.Forbidden f  => $"403 Forbidden: {f.PolicyId}",
        _                  => $"500 Internal: {error.Kind}",
    });

Creating errors

There are no static factory methods on the base Error type. Every call site names the case it produces:

new Error.NotFound(ResourceRef.For<Order>("42")) { Detail = "Order 42 not found" }

Detail is an init-only property on the base record; set it via object-initializer when you want to override the boundary renderer's default human-readable text. Kind is the stable domain slug and Code is the per-instance machine identifier exposed by that case.

For the common single-payload shapes, the resource-bearing cases (NotFound, Gone, Conflict, Forbidden, InvariantViolation) and InvalidInput also expose case-scoped convenience factories that bundle the typed payload while still naming the case — they only remove the ResourceRef / InputPointer construction ceremony:

Error.NotFound.For<Order>(id: id, detail: "Order not found");
Error.Conflict.ForReason(FaultCodes.DuplicateKey, detail: "Email in use");
Error.Forbidden.ForPolicy("orders.write", detail: "Admin required");
Error.InvariantViolation.ForReason("order.cross-aggregate-rule");
Error.InvalidInput.ForField(ValidationCodes.StringEmail, "email", detail: "Bad email");

Factories put code first and detail last. Validation, conflict, invariant, and policy codes must be nonblank; invalid codes throw argument exceptions even through constructors or with assignments. Application-specific codes remain supported. NotFound and Gone still allow omission (id: id); blank optional factory codes retain ValidationCodes.Unspecified. Explicit resources use For(code, ResourceRef.For("Order", id), detail: ...), not a string-resource overload. See the complete factory signatures.

Pattern Example
Resource not found new Error.NotFound(ResourceRef.For<Order>(id)) { Detail = $"Order {id} not found" }
State conflict new Error.Conflict(Resource: ResourceRef.For<User>(userId), Code: FaultCodes.DuplicateKey) { Detail = "Email is already in use" }
Domain rule conflict (no resource) new Error.Conflict(Resource: null, Code: "order.cancel-after-ship") { Detail = "Cannot cancel after shipment" }
Authentication missing new Error.AuthenticationRequired()
Authenticated but not allowed new Error.Forbidden("orders.write") { Detail = "Administrator role required" }
Soft-deleted resource new Error.Gone(ResourceRef.For<Document>(id))
Wrong content type new Error.TransportFault(new HttpError.UnsupportedMediaType(EquatableArray.Create("application/json")))
Body too large new Error.TransportFault(new HttpError.ContentTooLarge(10 * 1024 * 1024))
Method not supported new Error.TransportFault(new HttpError.MethodNotAllowed(EquatableArray.Create("GET", "POST")))
Rate limited new Error.RateLimited(new RetryAdvice(After: TimeSpan.FromSeconds(30)))
Dependency unavailable new Error.Unavailable(new RetryAdvice(After: TimeSpan.FromSeconds(120))) { Code = "payment-gateway.offline" }
Unhandled fault new Error.Unexpected("crm.timeout", faultId)
Aggregate invariant new Error.InvariantViolation("order.cross-aggregate-rule", ResourceRef.For<Order>(orderId))
Tip

Reach for new Error.Conflict(Resource: resource, Code: "domain.violation") when state blocks an otherwise-valid request. Error.InvalidInput is for input the caller can fix. Error.InvariantViolation is for domain rules that aren't bound to a specific request field (e.g. a cross-aggregate rule, an internal precondition).

Validation failures

Error.InvalidInput carries EquatableArray<FieldViolation> Fields and EquatableArray<RuleViolation> Rules. For single-violation cases prefer the static factories on the case itself; the boundary renderer surfaces a single field-violation's Detail as the response detail directly.

Factory Use when
Error.InvalidInput.ForField(code, field: propertyName, args: args, detail: detail) Single property failure, escaped via InputPointer.ForProperty; null/empty targets the root. args and detail are optional.
Error.InvalidInput.ForField(code, field: pointer, args: args, detail: detail) An existing InputPointer, preserving nested/array path and input location.
Error.InvalidInput.Required(fieldName, detail) Standard ValidationCodes.ValueNotNull violation; null/empty field targets the root.
Error.InvalidInput.Required(pointer, detail) The same required-field violation, preserving the pointer's path and input location.
Error.InvalidInput.ForRule(code, fields: fields, args: args, detail: detail) Object-level or cross-field invariant. Optional related pointers are defensively copied in order; args and detail are also optional.
new Error.InvalidInput(EquatableArray<FieldViolation> Fields, EquatableArray<RuleViolation> Rules = default) Aggregate multiple per-field and/or cross-field violations.
using System.Collections.Immutable;
using Trellis;

var single = Error.InvalidInput.ForField(field: "email", code: ValidationCodes.ValueNotEmpty, detail: "Email is required");

var indexedField = InputPointer.ForBody("/items").AppendIndex(2).AppendProperty("quantity");
var indexed = Error.InvalidInput.ForField(field: indexedField, code: ValidationCodes.ValueGreaterThan,
    detail: "Quantity must be greater than zero.");

var crossField = Error.InvalidInput.ForRule("password.confirmation-mismatch",
    fields: [InputPointer.ForBody("/password"), InputPointer.ForBody("/confirmation")],
    detail: "Password and confirmation differ.");

var multiField = new Error.InvalidInput(EquatableArray.Create(
    new FieldViolation(InputPointer.ForProperty("email"),    ValidationCodes.ValueNotEmpty) { Detail = "Email is required" },
    new FieldViolation(InputPointer.ForProperty("password"), ValidationCodes.StringMinLength,
        ValidationArgs.Of("minLength", 8)) { Detail = "Password must be at least 8 characters" },
    new FieldViolation(InputPointer.ForProperty("age"),      ValidationCodes.ValueGreaterThanOrEqual,
        ValidationArgs.Of("comparisonValue", 18)) { Detail = "Must be 18 or older" }));

var crossField = new Error.InvalidInput(
    Fields: EquatableArray<FieldViolation>.Empty,
    Rules:  EquatableArray.Create(new RuleViolation(
        "password.confirmation-mismatch",
        Fields: EquatableArray.Create(
            InputPointer.ForProperty("password"),
            InputPointer.ForProperty("passwordConfirmation")))
        { Detail = "Passwords must match" }));

AppendProperty appends a literal name and escapes ~ and /; it does not treat a leading slash as another pointer. AppendIndex rejects negative indexes. Both retain the parent pointer's input location, so the example points to /items/2/quantity in the body.

Required values

Result.EnsureNotNull guards a nullable reference or nullable struct and returns Result<T>, not Result<Unit>. Typed Combine/Map receives the values directly:

public sealed record CreateItemCommand(string Name, int Category)
{
    public static Result<CreateItemCommand> TryCreate(string? name, int? category) =>
        Result.EnsureNotNull(name, "name", "Name is required.")
            .Combine(Result.EnsureNotNull(category, "category", "Category is required."))
            .Map((name, category) => new CreateItemCommand(name, category));
}

Both missing fields are reported, and no ! is needed. The guard checks only null, not blank strings or default scalar values; add those rules separately when required. Field/detail forms construct no error or violation (and record no validation failure) on success. Custom failures can use Error or a lazy Func<Error>. String field names may be null/empty to target the root. Full JSON Pointers are validated only when the value is missing, so a malformed pointer throws ArgumentException on that path but is unused on success. For a nullable-returning query, use task.EnsureNotNullAsync(...) on Task<T?> / ValueTask<T?>; source faults/cancellation propagate and factories run only for a successful source completion with null. Factories must be non-null and are validated before awaiting. Core's nullable ToResult APIs and universal no-argument lift are removed; use Result.Ok(value) only for deliberate success wrapping. Maybe<T>.ToResult(error/factory) remains the ordinary-absence bridge.

For conditional requiredness or collection-element rules, use Error.InvalidInput.Required("kind", "Kind is required for this item."). Its pointer form preserves input location; use Result.EnsureNotNull(value, () => Error.InvalidInput.Required(pointer, detail)) for a lazy location-aware guard. Composite validators can construct FieldViolation.Required("kind", detail) or FieldViolation.Required(InputPointer.ForBody("/items").AppendIndex(2).AppendProperty("kind"), detail). Both use ValidationCodes.ValueNotNull, no structured args, and the supplied detail; the pointer form retains its input location. This does not change HTTP mappings.

Pattern matching

Use Match to fold a Result<T> into a value, or read result.Error (which is Error? and never throws) to perform side effects. The C# compiler verifies exhaustiveness against the closed catalog — adding a new case to Error lights up every switch that does not handle it.

var message = LoadUser("42").Match(
    onSuccess: user  => $"Found {user.Email}",
    onFailure: error => error switch
    {
        Error.InvalidInput uc => $"Bad input: {uc.GetDisplayMessage()}",
        Error.NotFound nf             => $"Missing {nf.Resource.Type} {nf.Resource.Id}",
        Error.Forbidden f             => $"Not allowed by {f.PolicyId}",
        _                             => $"Fallback: {error.Kind}",
    });

Match has async overloads for Task<Result<T>> and ValueTask<Result<T>> (MatchAsync) and tuple overloads for arities 2–9. Full signatures: trellis-api-core.md → Match family.

Kind vs Code

  • Kind is the stable, low-cardinality domain slug used for telemetry (e.g. "invalid-input", "invariant-violation", "not-found"). It is unaffected by payload values.
  • Code defaults to the sentinel ValidationCodes.Unspecified ("error.unspecified") — never to Kind. It is stored once on the base with an init accessor, so any case can name a reason the same way: new Error.NotFound(resource) { Code = "order.archived" }. Cases whose reason is required — Conflict, Forbidden (where PolicyId reads the same value), InvariantViolation, and Unexpected (which surfaces FaultId separately as a problem-details extension when set) — take it as a positional constructor parameter, so the compiler refuses one that says nothing. Code is what every boundary publishes, so a kind never reaches a consumer disguised as a reason.

At the ASP boundary, the wire kind can differ from the domain Kind for backward compatibility — most notably Error.InvalidInput / Error.InvariantViolation map to on-wire kind = "unprocessable-content".

Propagating errors

Operator Effect
TapOnFailure(action) Side effect (log/metric) on failure; result passes through unchanged.
MapOnFailure(error => newError) Translate one failure case into another (e.g. when crossing layers).
RecoverOnFailure(error => Result<T>) Replace a failure with a fallback Result (success or different failure).
var result = LoadFromCrm(id)
    .TapOnFailure(error => logger.LogWarning("CRM call failed: {Kind} {Code}", error.Kind, error.Code))
    .MapOnFailure(error => error switch
    {
        Error.Unexpected => new Error.Unavailable(new RetryAdvice(After: TimeSpan.FromSeconds(60)))
            { Code = "crm.offline", Detail = "Customer service is temporarily unavailable" },
        _ => error,
    });

static Result<string> GetFromCache()    => Result.Fail<string>(new Error.NotFound(ResourceRef.For<string>("user:42")));
static Result<string> GetFromDatabase() => Result.Ok("Ada Lovelace");

var withFallback = GetFromCache().RecoverOnFailure(_ => GetFromDatabase());

All three operators have Async variants on Task<Result<T>> and ValueTask<Result<T>>. See trellis-api-core.md → Tap, Map, Recover families.

Capturing exceptions

Expected failures should be regular Error values. For code that genuinely throws, Result.Try and Result.TryAsync bridge the gap. Without a custom map, the default mapping wraps the exception as new Error.Unexpected("unhandled-exception", faultId) with a generated FaultId.

using System.IO;
using Trellis;

static Result<string> LoadText(string path) =>
    Result.Try(() => File.ReadAllText(path));

static Task<Result<string>> LoadTextAsync(string path) =>
    Result.TryAsync(() => File.ReadAllTextAsync(path));

static Result<string> LoadConfig(string path) =>
    Result.Try(
        () => File.ReadAllText(path),
        exception => exception switch
        {
            FileNotFoundException       => new Error.NotFound(ResourceRef.For<FileInfo>(path)) { Detail = $"{path} was not found" },
            UnauthorizedAccessException => new Error.Forbidden("file.read") { Detail = "Access denied" },
            _                           => new Error.Unexpected("unhandled-exception", Guid.NewGuid().ToString("N")) { Detail = "An unexpected error occurred while loading the file." },
        });

Result.Try and Result.TryAsync also have parameterless-work overloads that return Result<Unit> for void/Task work — useful for command handlers, which return Result<Unit>.

Composition

Combine merges multiple Result<T> failures into one. Two distinct shapes:

Input failures Result
All Error.InvalidInput One merged Error.InvalidInput with concatenated Fields and Rules.
Heterogeneous (mixed cases) One Error.Aggregate wrapping the children. Nested aggregates are flattened at construction.
var emailErr    = Result.Fail(Error.InvalidInput.ForField(field: "email",    code: ValidationCodes.ValueNotEmpty));
var passwordErr = Result.Fail(Error.InvalidInput.ForField(field: "password", code: ValidationCodes.ValueNotEmpty));
var ageErr      = Result.Fail(Error.InvalidInput.ForField(field: "age",      code: ValidationCodes.ValueNotEmpty));

var combined = Result.Combine(emailErr, passwordErr, ageErr);
// combined.Error is one Error.InvalidInput with three Fields entries.

Error.Combine(left, right) (extension on Error?) does the same merging at the error level; passing a null left returns right.

Error? acc = null;
acc = acc.Combine(new Error.NotFound(ResourceRef.For<Order>("1")));
acc = acc.Combine(new Error.Forbidden("orders.write"));
// acc is Error.Aggregate { Errors = [NotFound, Forbidden] }

Error.Aggregate exposes three constructor overloads (EquatableArray<Error>, IEnumerable<Error>, params Error[]); all three throw ArgumentException if no errors are supplied, and all flatten nested aggregates.

For collection validation, use TraverseAll to accumulate failures from every item. Its indexed overload keeps input positions available for precise error pointers:

string?[] inputs = ["Ada", null, "Grace"];
var names = inputs.TraverseAll((name, index) =>
    Result.EnsureNotNull(name, () => Error.InvalidInput.ForField(
        field: InputPointer.Root.AppendProperty("names").AppendIndex(index),
        code: ValidationCodes.ValueNotNull, detail: "Name is required.")));
// Failure identifies /names/1; every input is examined.

For asynchronous validation, use TraverseAllAsync((item, index, ct) => ValidateAsync(item, index, ct), cancellationToken), where the validator is application-owned. Task and ValueTask selectors are awaited sequentially; the call-site token is optional, but indexed async selectors always take three parameters. This avoids ambiguity with the existing (item, cancellationToken) selector. Use the fail-fast Traverse family instead when later operations must not run after a failure.

Boundary mapping

Map an Error to an HTTP / Problem Details response with result.ToHttpResponse(...) from Trellis.Asp. The boundary decides HTTP status, companion headers, and the problem-details type / kind. That wire kind can differ from the domain Kind for backward compatibility — Error.InvalidInput.Kind is invalid-input, but the on-wire kind is unprocessable-content. For Result<Unit> the success branch emits 204 No Content.

using Trellis;
using Trellis.Asp;

public static IResult Get(string id, IUserService users) =>
    users.GetUser(id).ToHttpResponse();

Full mapping rules and per-case behaviour live in:

Practical guidance

  • Pick the case the caller can act on. InvalidInput when the request data can be fixed; Conflict when state or a business rule blocks an otherwise-valid request; InvariantViolation when the rule is real but not bound to a request field; NotFound when the resource does not exist; Gone for soft-deleted resources.
  • Use Unexpected(code, faultId?) for true surprises. Set FaultId when you need telemetry correlation; use Code == FaultCodes.NotImplemented only when you intentionally want HTTP 501 at the boundary.
  • Set Detail only when it adds information. Boundary renderers compute a usable default from Kind/Code and the typed payload; override Detail only when you have something more specific to say.
  • Prefer Match (or a property pattern on result.Error) at boundaries. Inside pipelines, prefer Bind/Map/Ensure over inspecting Error directly.
  • Use TapOnFailure for logging and metrics. It does not mutate the result.
  • Use MapOnFailure when crossing layers. Translating a low-level Unexpected from a dependency into a domain-meaningful Unavailable is a typical use.
  • Use Combine to preserve every failure. This is the difference between "first error wins" and "tell the caller everything that's wrong."
  • Equality. Two errors compare equal when Kind, payload, and Detail match. Cause is excluded from equality so the same surface failure raised from different code paths still compares equal.

InvalidInput vs InvariantViolation

InvalidInput carries field/rule violations bound to inbound request fields (per-field validation). InvariantViolation carries (Code, ResourceRef?) for domain rules that aren't tied to a request field — for example a cross-aggregate invariant or an internal precondition. Both map to HTTP 422 at the ASP boundary, but the wire code differs so consumers can tell domain-rule failures apart from field-validation failures.

Migration from v2.x

Removed Use instead
Error.BadRequest Error.InvalidInput.ForRule(code: ...) (or .ForField(...) if anchored to a request field)
Error.UnprocessableContent Error.InvalidInput
Error.Unauthorized Error.AuthenticationRequired
Error.TooManyRequests Error.RateLimited
Error.ServiceUnavailable Error.Unavailable
Error.InternalServerError Error.Unexpected
Error.NotImplemented Error.Unexpected with Code == FaultCodes.NotImplemented
Error.MethodNotAllowed / NotAcceptable / UnsupportedMediaType / RangeNotSatisfiable / ContentTooLarge / PreconditionFailed / PreconditionRequired Error.TransportFault(new HttpError.X(...))

For the full upgrade narrative, including telemetry-slug changes and AuthChallenge removal, see the project CHANGELOG.

Cross-references