ErrorApi.LanguageExt
1.0.1
dotnet add package ErrorApi.LanguageExt --version 1.0.1
NuGet\Install-Package ErrorApi.LanguageExt -Version 1.0.1
<PackageReference Include="ErrorApi.LanguageExt" Version="1.0.1" />
<PackageVersion Include="ErrorApi.LanguageExt" Version="1.0.1" />
<PackageReference Include="ErrorApi.LanguageExt" />
paket add ErrorApi.LanguageExt --version 1.0.1
#r "nuget: ErrorApi.LanguageExt, 1.0.1"
#:package ErrorApi.LanguageExt@1.0.1
#addin nuget:?package=ErrorApi.LanguageExt&version=1.0.1
#tool nuget:?package=ErrorApi.LanguageExt&version=1.0.1

ErrorApi.LanguageExt
Keep returning Fin<T> — and get an OpenAPI document that finally says which errors each endpoint can return.
dotnet add package ErrorApi.LanguageExt
A language-ext Error carries a numeric code and a message. Neither says anything about HTTP, so the
mapping ends up hand-written per endpoint — and the document still stops at 200 OK.
ErrorApi reads your error types at compile time, follows each endpoint handler through the call graph —
including through IOrderService into its implementation — and writes the answer into OpenAPI.
This package is the language-ext half: it turns a Fin, an Either or a bare Error into a problem
response that agrees with the document. It brings the generator with it; nothing else to install.
Before / after
Same feature, same Fin<T>, same services. Here is every line that changes.
1. The error types
Before:
using LanguageExt.Common;
public sealed record OrderNotFound(Guid Id) : Expected("Order not found", 404);
public sealed record OrderAlreadyPaid(Guid Id) : Expected("Order already paid", 409);
After — a bare attribute per type. Your Expected already carries the message and the status, so the
generator reads them from the base constructor call; the wire code comes from the name under the
catalog's prefix. Nothing is written twice:
using LanguageExt.Common;
[ErrorApi.ErrorCatalog("Orders")]
public static class OrderErrors
{
[ErrorApi.Error] // -> "Orders.NotFound", 404, "Order not found" — all read from the line below
public sealed record NotFound(Guid Id) : Expected("Order not found", 404);
[ErrorApi.Error] // -> "Orders.AlreadyPaid", 409
public sealed record AlreadyPaid(Guid Id) : Expected("Order already paid", 409);
}
Naming note. language-ext and ErrorApi both ship a type called
Error, so spell the attribute out as[ErrorApi.Error]— or addusing ErrorAttribute = ErrorApi.ErrorAttribute;to the file and keep writing[Error].
Everything stays overridable when you want it explicit: [ErrorApi.Error("Orders.NotFound", 404, Title = "…")]
spells it all out, and [ErrorApi.ErrorDescription("…")] adds documentation prose to an otherwise
bare entry.
2. The service
Not one line changes. This is the part people expect to have to rewrite, and do not:
public Fin<Order> GetById(Guid id) =>
_orders.TryGetValue(id, out var order) ? order : new OrderErrors.NotFound(id);
3. The endpoint
Before — the status mapping is written by hand at every call site, and the .Produces calls are a
second copy of the same knowledge that goes stale the first time the service gains a failure:
app.MapGet("/orders/{id:guid}", (Guid id, IOrderService s) =>
s.GetById(id).Match<IResult>(
order => TypedResults.Ok(order),
error => error.Code switch
{
404 => TypedResults.Problem(statusCode: 404, title: "Order not found"),
409 => TypedResults.Problem(statusCode: 409, title: "Order already paid"),
_ => TypedResults.Problem(statusCode: 500),
}))
.Produces<ProblemDetails>(404)
.Produces<ProblemDetails>(409);
After — the mapping comes from the catalog, and the documented responses are derived from the code:
app.MapGet("/orders/{id:guid}", (Guid id, IOrderService s) => s.GetById(id).ToHttpResult());
4. Start-up
Before:
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
After — one line, plus using ErrorApi.Interop; in the files that call ToHttpResult():
builder.Services.AddOpenApi();
builder.Services.AddErrorApi(); // generated overload — no reflection behind it
var app = builder.Build();
app.MapOpenApi();
app.MapErrorContract(); // optional: serves the TypeScript contract at /openapi/errors.ts
5. What the caller sees
Before, GET /orders/{id} promises only success in the document. The failures still happen; they are
simply not written down anywhere a client can read:
"responses": {
"200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Order" } } } }
}
After:
"responses": {
"200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Order" } } } },
"404": {
"description": "Not Found — Orders.NotFound",
"content": {
"application/problem+json": {
"schema": {
"required": ["status", "code"],
"properties": {
"status": { "enum": [404], "type": "integer" },
"code": { "enum": ["Orders.NotFound"], "type": "string" }
}
},
"examples": { "Orders.NotFound": { "value": { "status": 404, "code": "Orders.NotFound" } } }
}
}
}
}
The response body is RFC 9457 with the machine-readable code alongside:
{ "title": "Order not found", "status": 404, "detail": "Order not found", "code": "Orders.NotFound" }
And the generated TypeScript turns a catch full of guesses into a union the compiler checks — add a
failure server-side and the frontend build breaks instead of production:
// before: the shape is a guess, and the codes are folklore
catch (e) { if (e.status === 404) show("not found"); }
// after: generated from the same types the server compiled
export type GetOrdersByIdError = ApiProblem<"Orders.NotFound">;
How an error is resolved
| Situation | Result |
|---|---|
| The error's type is annotated | The catalog's code, status and title. The error's Message becomes detail. |
Not annotated, Code looks like a status |
That status is used, with Message as detail and the type name as the code. |
| Anything else | 500, with Message as detail. |
Resolution goes by type first, through a pattern switch the generator emits into your own assembly:
public ErrorDescriptor? FindErrorForInstance(object? instance) => instance switch
{
global::Shop.OrderNotFound => _errors[1],
_ => null,
};
That is the entire lookup — no reflection, nothing for the trimmer to keep alive. Falling back to the numeric code is a convenience for errors you did not write, not the intended path: an unannotated error gets a type name for a code, which is not a contract a client should depend on.
What you get
finResult.ToHttpResult(); // Fin<T> and Task<Fin<T>>
finResult.ToHttpResult(order => Results.Ok(order));
finResult.ToNoContentResult(); // 204
finResult.ToCreated(order => $"/orders/{order.Id}"); // 201, location built from the created value
finResult.ToCreatedAtRoute("GetOrder", order => new() { ["id"] = order.Id });
eitherResult.ToHttpResult(); // Either<Error, T>
error.ToProblem(); // an Error on its own
error.ToErrorApiError(); // resolve without producing a response
Every method that resolves an error takes an optional IErrorApiMetadata, so the behaviour is testable
without standing up a host.
You also get, from the core package, a TypeScript contract with one union per endpoint:
export type GetOrdersByIdError = ApiProblem<"Orders.NotFound">;
A switch over problem.code stops compiling the moment the API gains a failure the client does not
handle.
A runnable version of all this
dotnet run --project samples/Sample.LanguageExt.Api
The repository builds the same orders API four times — once on ErrorApi's own Result<T> and once
per adapter — so you can diff the declaration styles against each other. They produce byte-identical
contracts. Browse the document at http://localhost:5083/scalar, or read the generated model
under obj/generated/ after a build.
Compatibility
Built against LanguageExt.Core 4.4.9, and verified in CI against 4.4.0 and 4.4.9. Targets net10.0
and is native-AOT clean.
On the v5 beta? ErrorApi.LanguageExt.V5 is the same surface compiled against the 5.x API, shipped as
a prerelease that tracks the beta and goes stable the moment 5.0.0 does.
Full documentation
github.com/SideswipeN7/ErrorApi — how discovery works, the EAPI001–EAPI013
diagnostics, the TypeScript contract, and the ErrorOr and OneOf adapters.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ErrorApi.AspNetCore (>= 1.0.1)
- LanguageExt.Core (>= 4.4.9)
- Microsoft.AspNetCore.OpenApi (>= 10.0.11)
-
net8.0
- ErrorApi.AspNetCore (>= 1.0.1)
- LanguageExt.Core (>= 4.4.9)
-
net9.0
- ErrorApi.AspNetCore (>= 1.0.1)
- LanguageExt.Core (>= 4.4.9)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.