ErrorOrX

June 3, 2026 · View on GitHub

Source generator converting ErrorOr<T> handlers into ASP.NET Core Minimal API endpoints with full Native AOT support.

Automatic Routing (for Claude)

Always invoke /working-in-erroror skill when starting work in this repo.

TaskUse
ImplementationTask tool → erroror-generator-specialist
DebuggingTask tool → deep-debugger
Before completionRun dotnet build + dotnet test, show output
Cross-repo workInvoke /ancplua-ecosystem first

Quick Reference

dotnet build ErrorOrX.slnx
dotnet test --solution ErrorOrX.slnx # VERIFY
dotnet pack src/ErrorOrX/ErrorOrX.csproj -c Release
dotnet pack src/ErrorOrX.Generators/ErrorOrX.Generators.csproj -c Release

Note: The # VERIFY comment bypasses the MTP smart-test-filtering hook that blocks full suite runs. Always include it for verification runs.

What the Generator Does

Convert ErrorOr<T> handlers into fully-wired ASP.NET endpoints:

User writes:                         Generator produces:
[Get("/todos/{id:guid}")]             app.MapGet("/todos/{id:guid}", (Delegate)Invoke_Ep1)
ErrorOr<Todo> GetById(Guid id)  ->       .WithName("TodoApi_GetById")
                                         .WithMetadata(new ProducesResponseTypeMetadata(...))
                                         .RequireAuthorization("Admin")
                                         ;

                                     static async Task<Results<Ok<Todo>, ...>> Invoke_Ep1(HttpContext ctx)
                                     {
                                         return await Invoke_Ep1_Core(ctx);
                                     }

                                     static Task<Results<Ok<Todo>, ...>> Invoke_Ep1_Core(...)
                                     {
                                         var result = TodoApi.GetById(id);
                                         if (result.IsError) return ToProblem(result.Errors);
                                         return TypedResults.Ok(result.Value);
                                     }

Core Generator Patterns

Minimal Interface Principle

Generated code uses ONLY IsError, Errors, Value from ErrorOr<T>:

// CORRECT - minimal interface
if (result.IsError) return ToProblem(result.Errors);
return TypedResults.Ok(result.Value);

// NEVER emit - creates coupling to convenience API
return result.Match(
    value => TypedResults.Ok(value),
    errors => ToProblem(errors));

Why: Reduces runtime coupling, portable code, consistent across all code paths.

AOT Wrapper Pattern

Two-method pattern ensures Native AOT compatibility and OpenAPI visibility:

// Wrapper - returns typed Results<...> for OpenAPI metadata
// MapGet uses (Delegate)Invoke_Ep1 to force the Delegate overload
private static async Task<Results<Ok<Todo>, NotFound<PD>>> Invoke_Ep1(HttpContext ctx)
{
    return await Invoke_Ep1_Core(ctx);
}

// Core - returns typed Results<...> with handler logic
private static Task<Results<Ok<Todo>, NotFound<ProblemDetails>>> Invoke_Ep1_Core(HttpContext ctx)
{
    // ... handler logic using minimal interface
}

Why: Without (Delegate) cast, Func<HttpContext, Task<T>> matches RequestDelegate — endpoints become invisible to OpenAPI. The cast forces RequestDelegateFactory to process the delegate, enabling typed return inspection.

Middleware Emission

Wrapper delegates lose original method attributes. Generator MUST emit:

  • .RequireAuthorization() for [Authorize]
  • .RequireRateLimiting() for [EnableRateLimiting]
  • .CacheOutput() for [OutputCache]
  • .RequireCors() for [EnableCors]

Smart Parameter Binding

PriorityConditionBinding
1Explicit attribute ([FromBody], [FromServices], etc.)As specified
2Special types (HttpContext, CancellationToken)Auto-detected
3Parameter name matches route {param}Route
4Primitive type not in routeQuery
5Interface typeService
6Abstract typeService
7Service naming (I*Service, *Repository, *Handler, *Manager, *Provider, *Factory, *Client)Service
8POST/PUT/PATCH + complex typeBody
9GET/DELETE + complex typeError EOE021
10Final fallbackService
// Smart binding infers:
// - req -> Body (POST + complex)
// - svc -> Service (interface)
// - id -> Route (matches {id})
[Post("/todos")]
public static ErrorOr<Todo> Create(CreateTodoRequest req, ITodoService svc) => ...

// EOE021 error - GET with complex type requires explicit binding
[Get("/todos")]
public static ErrorOr<List<Todo>> Search(SearchFilter filter) => ...  // Error
public static ErrorOr<List<Todo>> Search([FromQuery] SearchFilter filter) => ...  // OK

ErrorType to HTTP Mapping (RFC 9110)

ErrorTypeHTTPTypedResult
Validation400ValidationProblem
Unauthorized401UnauthorizedHttpResult
Forbidden403ForbidHttpResult
NotFound404NotFound<ProblemDetails>
Conflict409Conflict<ProblemDetails>
Failure500InternalServerError<ProblemDetails>
Unexpected500InternalServerError<ProblemDetails>

Diagnostics Summary

CategoryIDsDescription
Handler validationEOE001-002Invalid return type, must be static
Route validationEOE003-005Unbound parameter, duplicate route, invalid pattern
Binding validationEOE006, EOE008-021Multiple body, invalid binding types, ambiguous binding
Union typesEOE022-024Too many types, unknown factory, undocumented interface
JSON/AOTEOE007, EOE025-026, EOE034-036Not serializable, missing CamelCase, missing context, validation reflection
API versioningEOE027-031Version-neutral conflicts, undeclared versions
Route/namingEOE032-033Duplicate route params, non-PascalCase method names

Consumer Setup

Minimal

var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();
app.MapErrorOrEndpoints();
app.Run();

With AOT JSON Context (Required for Native AOT)

// 1. Define your JSON context
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(YourRequestType))]
[JsonSerializable(typeof(YourResponseType))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(HttpValidationProblemDetails))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }

// 2. Register with ErrorOrEndpoints
builder.Services.AddErrorOrEndpoints(options => options
    .UseJsonContext<AppJsonSerializerContext>()
    .WithCamelCase()
    .WithIgnoreNulls());

var app = builder.Build();
app.MapErrorOrEndpoints();

Critical: Roslyn generators cannot see other generators' output. You MUST create your own JsonSerializerContext.

Source of Truth Files

FileOwns
Descriptors.csAll diagnostics (EOE001-EOE036)
ErrorMapping.csErrorType names, HTTP codes, TypedResult factories
Models/*.csAll data structures (EndpointDescriptor, EndpointParameters, RouteModels, ValidationModels, …)
WellKnownTypes.csAll FQN string constants
RouteValidator.csRoute validation, parameter lookup building

Project Structure

src/
  ErrorOrX/                    # Runtime library (net10.0)
  ErrorOrX.Generators/         # Source generator (netstandard2.0)
tests/
  ErrorOrX.Tests/              # Runtime unit tests
  ErrorOrX.Generators.Tests/   # Generator snapshot tests
  ErrorOrX.Integration.Tests/  # HTTP parity tests

Dependencies

PackageVersionPurpose
ANcpLua.Roslyn.Utilities.Sources2.2.26Incremental generator utilities
ANcpLua.Roslyn.Utilities.Testing2.2.26Generator testing framework
ANcpLua.Analyzers2.0.2Code quality analyzers
Microsoft.CodeAnalysis.CSharp5.3.0Roslyn APIs
xunit.v3.mtp-v23.2.2Testing framework
AwesomeAssertions(SDK)Fluent assertions (version from ANcpLua.NET.Sdk)

Before Writing New Code

Search for existing implementations first. Common duplication areas:

ConceptOwnerDo NOT duplicate in
Unwrap nullableErrorOrContext.UnwrapNullable(ITypeSymbol)TypeNameHelper.UnwrapNullable(string)
Type comparisonRoslyn ITypeSymbol.EqualsTypeNameHelper.TypeNamesMatch()
Route parameters-RouteValidator.BuildRouteParameterLookup()
Param binding emitBindingCodeEmitterEmitter.cs (call directly, no wrappers)
Wrap return exprsInvokerContext.WrapReturn()Local functions in emit methods