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.
| Task | Use |
|---|---|
| Implementation | Task tool → erroror-generator-specialist |
| Debugging | Task tool → deep-debugger |
| Before completion | Run dotnet build + dotnet test, show output |
| Cross-repo work | Invoke /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
# VERIFYcomment 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
| Priority | Condition | Binding |
|---|---|---|
| 1 | Explicit attribute ([FromBody], [FromServices], etc.) | As specified |
| 2 | Special types (HttpContext, CancellationToken) | Auto-detected |
| 3 | Parameter name matches route {param} | Route |
| 4 | Primitive type not in route | Query |
| 5 | Interface type | Service |
| 6 | Abstract type | Service |
| 7 | Service naming (I*Service, *Repository, *Handler, *Manager, *Provider, *Factory, *Client) | Service |
| 8 | POST/PUT/PATCH + complex type | Body |
| 9 | GET/DELETE + complex type | Error EOE021 |
| 10 | Final fallback | Service |
// 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)
| ErrorType | HTTP | TypedResult |
|---|---|---|
| Validation | 400 | ValidationProblem |
| Unauthorized | 401 | UnauthorizedHttpResult |
| Forbidden | 403 | ForbidHttpResult |
| NotFound | 404 | NotFound<ProblemDetails> |
| Conflict | 409 | Conflict<ProblemDetails> |
| Failure | 500 | InternalServerError<ProblemDetails> |
| Unexpected | 500 | InternalServerError<ProblemDetails> |
Diagnostics Summary
| Category | IDs | Description |
|---|---|---|
| Handler validation | EOE001-002 | Invalid return type, must be static |
| Route validation | EOE003-005 | Unbound parameter, duplicate route, invalid pattern |
| Binding validation | EOE006, EOE008-021 | Multiple body, invalid binding types, ambiguous binding |
| Union types | EOE022-024 | Too many types, unknown factory, undocumented interface |
| JSON/AOT | EOE007, EOE025-026, EOE034-036 | Not serializable, missing CamelCase, missing context, validation reflection |
| API versioning | EOE027-031 | Version-neutral conflicts, undeclared versions |
| Route/naming | EOE032-033 | Duplicate 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
| File | Owns |
|---|---|
Descriptors.cs | All diagnostics (EOE001-EOE036) |
ErrorMapping.cs | ErrorType names, HTTP codes, TypedResult factories |
Models/*.cs | All data structures (EndpointDescriptor, EndpointParameters, RouteModels, ValidationModels, …) |
WellKnownTypes.cs | All FQN string constants |
RouteValidator.cs | Route 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
| Package | Version | Purpose |
|---|---|---|
| ANcpLua.Roslyn.Utilities.Sources | 2.2.26 | Incremental generator utilities |
| ANcpLua.Roslyn.Utilities.Testing | 2.2.26 | Generator testing framework |
| ANcpLua.Analyzers | 2.0.2 | Code quality analyzers |
| Microsoft.CodeAnalysis.CSharp | 5.3.0 | Roslyn APIs |
| xunit.v3.mtp-v2 | 3.2.2 | Testing framework |
| AwesomeAssertions | (SDK) | Fluent assertions (version from ANcpLua.NET.Sdk) |
Before Writing New Code
Search for existing implementations first. Common duplication areas:
| Concept | Owner | Do NOT duplicate in |
|---|---|---|
| Unwrap nullable | ErrorOrContext.UnwrapNullable(ITypeSymbol) | TypeNameHelper.UnwrapNullable(string) |
| Type comparison | Roslyn ITypeSymbol.Equals | TypeNameHelper.TypeNamesMatch() |
| Route parameters | - | RouteValidator.BuildRouteParameterLookup() |
| Param binding emit | BindingCodeEmitter | Emitter.cs (call directly, no wrappers) |
| Wrap return exprs | InvokerContext.WrapReturn() | Local functions in emit methods |