EitherWay 1.2.2
dotnet add package EitherWay --version 1.2.2
NuGet\Install-Package EitherWay -Version 1.2.2
<PackageReference Include="EitherWay" Version="1.2.2" />
<PackageVersion Include="EitherWay" Version="1.2.2" />
<PackageReference Include="EitherWay" />
paket add EitherWay --version 1.2.2
#r "nuget: EitherWay, 1.2.2"
#:package EitherWay@1.2.2
#addin nuget:?package=EitherWay&version=1.2.2
#tool nuget:?package=EitherWay&version=1.2.2
EitherWay
Errors as values. Not exceptions.
EitherWay is a functional error-handling library for C# that eliminates try-catch sprawl and makes failure paths explicit in your method signatures. Built on the Either monad pattern, it brings Railway-Oriented Programming to .NET with minimal ceremony.
dotnet add package EitherWay
Includes ASP.NET Core controller extensions (HandleResult, HandleCreated, async overloads) — no extra package needed.
Philosophy
Exceptions are invisible control flow. They jump across layers, bubble up unexpectedly, and hide in places you don't expect. EitherWay flips that:
- Errors are values. A method that can fail says so in its return type.
- No try-catch noise. Business logic stays clean; error handling stays explicit.
- Short-circuit by design. Once something fails, the pipeline stops. You don't check
if (error)at every step. - The compiler is your safety net. If you don't handle the error case, it won't compile.
// Before: exception-driven
public async Task<Company> GetCompany(int id)
{
try
{
var company = await _repo.GetById(id);
if (company == null)
throw new NotFoundException("Company not found");
return company;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get company");
throw;
}
}
// After: error-driven
// Option 1: static Try with MapLeft (recommended for new code)
public async Task<Either<string, Company>> GetCompany(int id)
=> await EitherAsync
.Try(() => _repo.GetByIdAsync(id))
.MapLeft(_ => "Database error")
.Ensure(c => c != null, "Company not found")
.Run();
// Option 2: extension FlatMap with handler (no MapLeft needed)
public EitherAsync<string, Company> GetCompanyV2(int id)
=> EitherAsync.Right(id)
.FlatMap(_ => _repo.GetById(id), ex => ex.Message)
.Ensure(c => c != null, "Company not found");
// Option 3: static Try with direct error value (cleanest when you don't need the exception)
public async Task<Either<string, Company>> GetCompanyV3(int id)
=> await EitherAsync
.Try(() => _repo.GetByIdAsync(id), "Database error")
.Ensure(c => c != null, "Company not found")
.Run();
Structure
EitherWay/ ← Core library (no dependencies)
├── Either<L, R> ← Discriminated union: Left (error) / Right (success)
├── EitherAsync<L, R> ← Lazy async: composes without executing until awaited
├── Unit ← Void result for command operations
├── Fluent extensions ← Map, FlatMap, Ensure, Tap, MapLeft, BiMap
├── LINQ support ← Select / SelectMany (from...select syntax)
└── Factories ← Either.Ok, Either.Fail, EitherAsync.Right, EitherAsync.Left
ControllerExtensions ← HandleResult, HandleCreated (sync + async) — built-in
Quickstart
Basic Either
using EitherWay;
// Success
Either<string, int> ok = Either.Ok(42);
// Failure
Either<string, int> fail = Either.Fail<int>("something went wrong");
// Pattern match
var message = ok.Match(
error => $"Error: {error}",
value => $"Value: {value}");
Async pipeline
public EitherAsync<string, Company> CreateCompany(Company company)
{
return EitherAsync.Right(company)
.Ensure(c => !string.IsNullOrEmpty(c.Name), "Name is required")
.Ensure(c => !string.IsNullOrEmpty(c.Address), "Address is required")
.FlatMap(async _ =>
{
var db = await _repo.CreateRecord(company);
await _repo.Save();
return db;
}, ex => ex.Message);
}
Controller
[ApiController]
public class CompanyController : ControllerBase
{
[HttpPost]
public async Task<ActionResult<Company>> Create(Company company)
=> await _service.CreateCompany(company).HandleResultAsync();
}
Void operations
public EitherAsync<string, Unit> DeleteCompany(int id)
=> EitherAsync.Right(id)
.Ensure(id => id > 0, "Invalid ID")
.FlatMap(_ => _repo.Delete(id), ex => ex.Message);
// In controller:
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
=> await _service.DeleteCompany(id).HandleResultAsync(); // 204 No Content
LINQ query syntax
var result = from a in EitherAsync.Right(3)
from b in EitherAsync.Right(4)
select a * b;
// → Right(12)
API Reference
Core types
| Type | Description |
|---|---|
Either<L, R> |
Discriminated union. Left = error, Right = success. |
EitherAsync<L, R> |
Lazy async wrapper. Composes fluently, runs on await. |
Unit |
Void type for command operations (create, update, delete). |
Factories
Either.Ok(value)
Creates a successful Either<string, T>. The error type defaults to string.
Either<string, Company> result = Either.Ok(company);
Either.Fail<T>(error)
Creates a failed Either<string, T> with an error message.
Either<string, int> result = Either.Fail<int>("invalid operation");
EitherAsync.Right(value)
Creates a lazy async EitherAsync<string, T> in the success track.
EitherAsync<string, Company> op = EitherAsync.Right(company);
EitherAsync.Left<T>(error)
Creates a lazy async EitherAsync<string, T> in the error track.
EitherAsync<string, int> op = EitherAsync.Left<int>("not found");
Either<L, R>.ToRight(value)
Creates a successful Either<L, R> with explicit types. Use when your error type is not string.
Either<ErrorCode, int> result = Either<ErrorCode, int>.ToRight(42);
Either<L, R>.ToLeft(error)
Creates a failed Either<L, R> with explicit types.
Either<ErrorCode, int> result = Either<ErrorCode, int>.ToLeft(ErrorCode.NotFound);
EitherAsync<L, R>.FromRight(value)
Creates a lazy async EitherAsync<L, R> in the success track with explicit types.
EitherAsync<ErrorCode, int> op = EitherAsync<ErrorCode, int>.FromRight(42);
EitherAsync<L, R>.FromLeft(error)
Creates a lazy async EitherAsync<L, R> in the error track with explicit types.
EitherAsync<ErrorCode, int> op = EitherAsync<ErrorCode, int>.FromLeft(ErrorCode.NotFound);
EitherAsync.Try(action) (static, no handler)
Safely executes an async operation without providing an error handler. The raw Exception becomes the Left value. Use MapLeft to project it to your error type.
var result = await EitherAsync
.Try(() => _repo.GetByIdAsync(id))
.MapLeft(_ => "Database error")
.Run();
EitherAsync<L, R>.Try(action, onError)
Safely executes an async operation. If it throws, the exception is caught and transformed into a Left value via onError. This is a static factory — use it to start a pipeline from a risky operation.
var op = EitherAsync<string, int>.Try(
() => httpClient.GetAsync("https://api.example.com/data"),
ex => $"Request failed: {ex.Message}"
);
EitherAsync.Try(action, error) (static, direct error value)
Safely executes an async operation. If it throws, the exception is discarded and your error value is used directly as the Left. You don't need a handler — the error is provided upfront.
var result = await EitherAsync
.Try(() => _repo.GetByIdAsync(id), new AppError("Database error"))
.Ensure(user => user is not null, new AppError("User not found"))
.Run();
Use this when you don't care about the exception details — only that the operation failed. The error value is captured once at construction time.
🚨 Static Try vs Extension FlatMap — cómo distinguirlos
Son dos métodos distintos con propósitos diferentes:
EitherAsync.Try(...) → estático, arranca un pipeline
Son métodos estáticos en la clase EitherAsync. No reciben un valor previo — inician el pipeline desde cero. El tipo error L se define acá.
| Overload | Firma | Handler? |
|---|---|---|
| Sin handler | Try<R>(action) |
❌ No — la Exception cruda es el Left |
| Handler | Try<L,R>(action, handler) |
✅ Func<Exception, L> |
| Error directo | Try<L,R>(action, error) |
❌ No — usás un valor fijo |
// Arranca desde cero — no hay valor previo
EitherAsync.Try(() => _repo.GetByIdAsync(id))
.FlatMap(action, handler) → extensión, CONTINÚA un pipeline
Es un método de extensión sobre EitherAsync<L,R>. Recibe el valor Right del paso anterior. Siempre lleva handler porque el tipo de error L ya está definido.
| Firma | Handler? |
|---|---|
FlatMap<L,R,T>(this ..., Func<R,Task<T>>, Func<Exception,L>) |
✅ Obligatorio |
// CONTINÚA — recibe el valor del paso anterior
EitherAsync.Right(companyId)
.FlatMap(async id => await _repo.GetById(id), ex => ex.Message)
Ejemplo real — CreateUser
return await EitherAsync
// ⬇️ Estático: arranca desde cero, NO recibe valor previo
// Puede NO llevar handler porque la Exception cruda es el Left
.Try(() => unitOfWork.Users.GetByUsernameAsync(request.Username, ct))
.MapLeft(ex => new AppError(ex.Message))
.Ensure(user => user is not null, new AppError("username already exists"))
.Map(user => request.Project())
// ⬇️ Extensión FlatMap: SÍ recibe el user del Map anterior
// DEBE llevar handler porque el tipo AppError ya está definido
.FlatMap(async user =>
{
user.PasswordHash = passwordHasher.Hash(request.Password);
await unitOfWork.Users.AddUserAsync(user, ct);
await unitOfWork.CommitAsync(ct);
return user;
}, exception => new AppError($"Failed to create user: {exception.Message}"))
.Map(user => user.MapTo<User, UserDto>())
.Run();
Regla de oro:
- Si arrancás un pipeline →
EitherAsync.Try(...)estático (puede o no llevar handler) - Si seguís un pipeline y la operación puede fallar →
.FlatMap(action, handler)extensión (SIEMPRE lleva handler)
Extensions on Either
.Map(fn)
Transforms the Right value with a synchronous function. If the state is Left, the function is skipped and the Left passes through unchanged.
Either<string, int> ok = Either.Ok(21);
var doubled = ok.Map(x => x * 2);
// → Right(42)
Either<string, int> fail = Either.Fail<int>("error");
var doubled = fail.Map(x => x * 2);
// → Left("error") — unchanged
.FlatMap(fn)
Chains a function that returns a new Either. Use this when the next operation can also fail. If the current state is Left, the function is skipped.
Either<string, int> ok = Either.Ok(10);
var chained = ok.FlatMap(x =>
x > 5
? Either<string, string>.ToRight($"big: {x}")
: Either<string, string>.ToLeft("too small")
);
// → Right("big: 10")
.Ensure(predicate, error)
Guard clause. If the Right value satisfies the predicate, it passes through. If not, the pipeline switches to Left with the given error. If the state is already Left, the predicate is skipped.
Either<string, int> ok = Either.Ok(-5);
var result = ok.Ensure(x => x > 0, "must be positive");
// → Left("must be positive")
.Ensure(predicate, errorFactory)
Same as Ensure but the error is lazily created from the Right value. Use this when the error message depends on the value.
Either<string, int> ok = Either.Ok(-5);
var result = ok.Ensure(x => x > 0, x => $"value {x} is invalid");
// → Left("value -5 is invalid")
.Ensure(predicate, errorFactory) — with Func<L>
Same as Ensure but the error factory doesn't take any parameters. Cleaner when the error doesn't depend on the Right value.
Either<string, int> ok = Either.Ok(-5);
var result = ok.Ensure(x => x > 0, () => "must be positive");
// → Left("must be positive")
.MapLeft(fn)
Transforms the Left value while leaving the Right unchanged. Useful when you need to convert error types at layer boundaries.
Either<int, string> either = Either<int, string>.ToLeft(404);
var result = either.MapLeft(code => $"HTTP {code}");
// → Left("HTTP 404")
.BiMap(leftFn, rightFn)
Transforms both Left and Right values simultaneously. You provide one function for each side.
Either<int, string> right = Either<int, string>.ToRight("hello");
var r1 = right.BiMap(
code => $"err:{code}",
text => text.Length
);
// → Right(5)
Either<int, string> left = Either<int, string>.ToLeft(42);
var l1 = left.BiMap(
code => $"err:{code}",
text => text.Length
);
// → Left("err:42")
.Tap(action)
Executes a side-effect action if the state is Right. The Either value is returned unchanged. Use this for logging, metrics, or caching without breaking the chain.
Either<string, int> ok = Either.Ok(42);
ok.Tap(x => Console.WriteLine($"Processing {x}"));
// → Still Right(42), side effect executed
.Match(onLeft, onRight)
Resolves the Either into a single value by providing handlers for both cases. This is the only way to extract the value. The compiler forces you to handle both paths.
Either<string, int> either = Either.Ok(42);
// Returns a string regardless of Left or Right
var message = either.Match(
left => $"Error: {left}",
right => $"Value: {right}"
);
// → "Value: 42"
// In a controller — maps to different HTTP responses
return either.Match<IActionResult>(
left => BadRequest(new { error = left }),
right => Ok(right)
);
Extensions on EitherAsync
.Map(fn)
Transforms the Right value with a synchronous function. If the pipeline is in the Left state, the function is skipped.
var asyncOp = EitherAsync.Right(21);
var result = await asyncOp.Map(x => x * 2).Run();
// → Right(42)
.FlatMap(fn)
Chains an async operation that returns a new Either. Accepts both sync and async functions.
// Async
var result = await asyncOp
.FlatMap(x => Task.FromResult(Either<string, string>.ToRight($"value: {x}")))
.Run();
// Sync (the library provides the overload)
var result = await asyncOp
.FlatMap(x => Either<string, string>.ToRight($"value: {x}"))
.Run();
.Ensure(predicate, error)
Guard clause for async pipelines. If the predicate fails, the pipeline switches to Left.
var result = await EitherAsync.Right(-5)
.Ensure(x => x > 0, "must be positive")
.Run();
// → Left("must be positive")
.Ensure(predicate, errorFactory)
Guard clause with a lazy error factory that receives the Right value.
var result = await EitherAsync.Right(-5)
.Ensure(x => x > 0, x => $"value {x} is invalid")
.Run();
// → Left("value -5 is invalid")
.Ensure(predicate, errorFactory) — with Func<L>
Same as above but the error factory doesn't take any parameters. Useful when the error doesn't depend on the Right value and you want a cleaner lambda.
var result = await EitherAsync.Right(-5)
.Ensure(x => x > 0, () => "must be positive")
.Run();
// → Left("must be positive")
.FlatMap(action, onError)
Safely executes an async operation as part of a pipeline. The action receives the Right value from the previous step. If it throws, the exception is caught and mapped to a Left. Use _ as the parameter name when you don't need the incoming value.
// Receives the previous value
var result = await EitherAsync.Right(companyId)
.FlatMap(async id => await _repo.GetById(id), ex => ex.Message)
.Run();
// Ignores the previous value (use _)
var result = await EitherAsync.Right(company)
.FlatMap(async _ => {
var db = await _repo.CreateRecord(company);
await _repo.Save();
return db;
}, ex => ex.Message)
.Run();
EitherAsync.Try(action) (static, no handler)
Safely executes an async operation without providing an error handler. The raw Exception becomes the Left value. Chain .MapLeft to project it to your error type.
var result = await EitherAsync
.Try(() => _repo.GetByIdAsync(id))
.MapLeft(_ => "Database error")
.Ensure(user => user is not null, () => "User not found")
.Run();
EitherAsync.Try(action, error) (static, direct error value)
Safely executes an async operation. If it throws, the exception is discarded and your error value is used directly as the Left. This is the cleanest option when you don't need the exception details.
var result = await EitherAsync
.Try(() => _repo.GetByIdAsync(id), "Database error")
.Ensure(user => user is not null, "User not found")
.Run();
.MapLeft(fn)
Transforms the Left value in an async pipeline.
var result = await EitherAsync.Left<int>(404)
.MapLeft(code => $"HTTP {code}")
.Run();
// → Left("HTTP 404")
.BiMap(leftFn, rightFn)
Transforms both Left and Right in an async pipeline.
var result = await EitherAsync.Right(21)
.BiMap(
error => $"err: {error}",
value => value * 2
)
.Run();
// → Right(42)
.Tap(action)
Executes a side-effect on the Right value without transforming it.
await EitherAsync.Right(42)
.Tap(x => Console.WriteLine($"Processing {x}"))
.Run();
// Still Right(42), side effect executed
.MatchAsync(onLeft, onRight)
Resolves an EitherAsync by running the pipeline and matching the result. Available with sync or async handlers.
// Sync handlers
var message = await asyncOp.MatchAsync(
error => $"Error: {error}",
value => $"Value: {value}"
);
// Async handlers
var httpResult = await asyncOp.MatchAsync(
async error => {
await _logger.LogErrorAsync(error);
return StatusCode(500, new { error });
},
value => Ok(value) // sync is fine too
);
.Run()
Executes the lazy async pipeline and returns the raw Either<L, R>. Use this when you want to pass the result to another function or use Match directly.
var either = await asyncOp.Run();
// either is Either<string, Company> — call Match on it
LINQ extensions
Select / SelectMany
Enables C# query comprehension syntax (from...select). Works on both Either and EitherAsync.
// Synchronous
var result = from a in Either<string, int>.ToRight(3)
from b in Either<string, int>.ToRight(4)
select a * b;
// → Right(12)
// Asynchronous
var result = await (from a in EitherAsync.Right(3)
from b in EitherAsync.Right(4)
select a * b).Run();
// → Right(12)
// Short-circuit on failure
var result = from a in Either<string, int>.ToRight(3)
from b in Either<string, int>.ToLeft("fail")
select a * b;
// → Left("fail") — second from fails, select is skipped
Note:
from...where...selectquery syntax is not supported because the LINQWherepattern only provides the predicate — there's nowhere to pass the error value. Use.Ensure(predicate, error)in method chains instead. It's clearer and works the same way.
Controller extensions (built-in)
HandleResult<T>()
Maps an Either<string, T> to ActionResult<T>. Right → 200 OK with the value. Left → 400 BadRequest or 404 NotFound depending on the error message content.
[HttpGet("{id}")]
public ActionResult<Company> Get(int id)
=> _service.GetCompany(id).HandleResult();
HandleResult() (for Unit)
Maps an Either<string, Unit> to IActionResult. Right → 204 No Content. Left → error response.
[HttpDelete("{id}")]
public IActionResult Delete(int id)
=> _service.DeleteCompany(id).HandleResult();
HandleCreated(routeName, idSelector)
Maps a successful creation to 201 Created with a Location header. The idSelector extracts route values for the CreatedAtRoute result.
[HttpPost]
public ActionResult<Company> Create(Company company)
{
return _service.CreateCompany(company).HandleCreated(
routeName: "GetCompany", // the named route for retrieval
idSelector: c => c.Id // "{id}" in the route
);
}
// For routes with multiple parameters:
return service.Create(order).HandleCreated(
routeName: "GetOrderItem",
idSelector: o => new { orderId = o.OrderId, itemId = o.ItemId }
);
HandleResultAsync<T>()
Async version of HandleResult<T>(). Calls Run() internally and maps the result.
[HttpGet("{id}")]
public async Task<ActionResult<Company>> Get(int id)
=> await _service.GetCompanyAsync(id).HandleResultAsync();
HandleResultAsync() (for Unit)
Async version for Unit results.
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
=> await _service.DeleteCompanyAsync(id).HandleResultAsync();
HandleCreatedAsync(routeName, idSelector)
Async version of HandleCreated.
[HttpPost]
public async Task<ActionResult<Company>> Create(Company company)
=> await _service.CreateCompanyAsync(company)
.HandleCreatedAsync("GetCompany", c => c.Id);
Error mapping logic
The library automatically maps error messages to HTTP status codes:
| Error contains | HTTP Status |
|---|---|
"not found" or "not exist" |
404 NotFound |
| Everything else | 400 BadRequest |
// 400 BadRequest
{ "message": "Name is required" }
// 404 NotFound
{ "message": "Company not found" }
Requirements
- .NET 10+
- C# 14+
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.