Have you ever needed a way to return different types depending on the situation? Perhaps a payment processor that returns different payment types, an order that can be in different states containing different data, or better yet, a file loader that handles multiple formats?
In C#, we usually solve this with inheritance hierarchies, marker interfaces, or wrapper objects. But luckily, there’s a better way: differential unions using the Winf library.
You may be familiar with union types if you’ve programmed with TypeScript before, as they’re one of the main features of the language. Union types are not a concept that can be found natively within C#, but are planned for a future release. Until then, you can use OneOf The library
In this article, I show you how OneOf From polymorphic return types to state machines to even elegant error handling, it enables you to write cleaner, more expressive, and type-safe code in a variety of scenarios, bringing F#-like distinction unions to C#.
Table of Contents
What is one?
The winf package offers distinct unions for C#, allowing you to return one of several predefined types from a single method. In contrast to a Tuplewhich bundles multiple values ​​together (a And b), a choice represents a choice (a or B or c).
Think of it as a type-safe method: “This method returns either type A, or type B, or type C” – and the compiler enforces that you handle all possibilities.
public (User user, Error error) GetUser(int id) { ... }
public OneOf GetUser(int id) { ... }
Why is there a difference?
Type protection: The compiler makes sure you handle every possible return type
Self-documentation: Method signatures clearly show all possible outcomes
No inheritance is required: Returns different types without forcing them into a class hierarchy
Pattern matching: Usage
.Match()To handle each case completelyflexibility: Supports 2, 3, 4+ different return types as needed
Installing an off
Option 1 (recommended):
Using Terminal, go to your project folder and run the command below:
dotnet add package OneOf
Option 2:
Using your IDE (Visual Studio, Ryder, or VS Code):
Right click on your project file
Select “Manage Nuget Packages”.
Search for “oneof”.
Click Install
Basic concepts and functionality
There are more than one basic concept that you will need to consider in order to get the most out of a one-of-a-kind library and understand its true benefits. These are:
Types of Union: One of many
At its heart, One represents a type of union. A value that can be one of several predefined types at any given time. Think of it as a type-safe container that holds exactly one value, but that value can be any type you define.
OneOf<string, int, bool> myValue;
myValue = "hello";
myValue = 42;
myValue = true;
It is fundamentally different from C# Tuple Type, which holds multiple values ​​simultaneously:
var tuple = ("hello", 42, true);
OneOf<string, int, bool> union = "hello";
Type safety and perfect handling
Winf isn’t just simple, it’s compiler. When you work with a single value, the compiler makes sure that you handle all possible types within it. .Match() Method This eliminates a whole bunch of bugs where you forget to handle a case.
For example:
OneOf result = GetResult();
result.Match(
success => HandleSuccess(success),
failure => HandleFailure(failure),
);
You’ll get a compiler warning and if you hover over it in your IDE or code editor you’ll see a prompt like this:

.Match() method
.Match() Method is one of Winoff’s killer features. This requires you to provide a handler function for every possible type in your union, making sure you never forget to handle the case.
Think of it as a typesafe switch statement that the compiler implements:
OneOf result = GetPaymentMethod();
result.Match(
creditCard => ProcessCreditCard(creditCard),
paypal => ProcessPayPal(paypal),
crypto => ProcessCrypto(crypto)
);
how met() function:
An OF determines what type of value is currently present
It executes the corresponding handler function for that type
This passes the actual value (with the correct type) to your handler
It returns the result of whatever handler was executed
General ordering matters, particularly in relation to .Match() method and defined handlers.

Common typing order: If you declare
OneOfthenCreditCardisT0for , for , for , .PayPalisT1,AndCryptoWalletisT2. This order determines which handler is which.Match(...)Will be hanged, not sworn.Handler parameter names are arbitrary: You can name them
option1for , for , for , .fooorcreditCard. The name does not determine the type, the position does. The compiler binds the first handlerCreditCardfrom anotherPayPaland the third cryptocurrency.Each handler gets a strong type parameter corresponding to its position. When the first handler runs, its parameter is one
CreditCardObject (with full intelligence and compile-time checking).For readability, prefer meaningful names (eg,
creditCardfor , for , for , .payPalfor , for , for , .crypto) instead ofoption1/2/3as this was for demonstration purposes only.
Accessing values
While .Match() The recommended approach is that Winf also provides direct type checking and access, although quite cumbersome and not as intuitive.
OneOf<string, int> example = "hello";
if (example.IsT0)
{
string str = example.AsT0;
Console.WriteLine(str);
}
else if (example.IsT1)
{
int num = example.AsT1;
Console.WriteLine(num);
}
You should avoid this approach in most cases for several reasons:
First, you lose the compiler implementation .Match() Want to add a third type later so powerful? The compiler will remind you to handle it here, and your code may become brittle and more prone to failure.
Secondly, it is verb and disorder. Instead of cleaning one .Match() call, you need multiple IF-else blocks which make your code harder to read and maintain.
third, T0for , for , for , . T1for , for , for , . T2 The naming convention is spot on and confusing. What type was it? T0 Once again? You have to constantly refer to the method signature to remember the order, which can be frustrating for you and the development team.
Finally, it is error prone. Nothing prevents you from forgetting to check IsT2 When dealing with three or more species.
use .Match() Whenever possible. Resort only IsT0/AsT0 When you have a specific reason to check only one type, and the others are irrelevant to the current code flow.
Exception driven control flow solution
Many codebases overuse exceptions for control flow, making the code difficult to follow and debug. When you see a method call, the signature doesn’t indicate whether it might throw an exception or what kind of errors should be expected. This creates several problems:
Hidden control flow:
public User GetUser(int id)
{
var user = _dbContext.Users.Find(id);
if (user == null)
throw new UserNotFoundException();
return user;
}
var user = _userService.GetUser(123);
Console.WriteLine(user.Name);
Exceptions as expected results
When a user enters an invalid email or no record is found, these are not real Unusual Conditions – These are expected, predictable outcomes that should be part of your normal business logic. Using exceptions for these scenarios renders routine validation as a crisis.
Effect of performance in hot routes
Although not always critical, throwing an exception involves unbrowsing the stack which can be hundreds of times slower than returning a value. In tight loops or high-throughput APIs, this overhead accumulates quickly.
try
{
var user = _userService.GetUser(id);
var order = _orderService.CreateOrder(user);
var payment = _paymentService.ProcessPayment(order);
}
catch (Exception ex)
{
return StatusCode(500, "Something went wrong");
}
Winf provides a neat alternative
Winf makes failures explicit, type-safe, and a part of the method signature. When you see a method that returns OneOfyou immediately. Know:
This method may fail
You must handle both success and failure
The compiler will implement this
The following code shows how to implement this:
public record Success<T>(T Value);
public record Failure(ErrorType Type, string() Messages);
public enum ErrorType
{
Validation,
NotFound,
Database,
Conflict,
}
public OneOf, Failure> GetUser(int id)
{
try
{
var user = _dbContext.Users.Find(id);
if (user == null)
return new Failure(ErrorType.NotFound, new() { $"User {id} not found" });
return new Success(user);
}
catch (DbException ex)
{
return new Failure(ErrorType.Database, new() { "Database error", ex.Message });
}
}
public IActionResult GetUserEndpoint(int id)
{
var result = _userService.GetUser(id);
return result.Match(
success => Ok(success.Value),
failure => failure.Type switch
{
ErrorType.NotFound => NotFound(new { errors = failure.Messages }),
ErrorType.Database => StatusCode(500, new { errors = failure.Messages }),
ErrorType.Validation => BadRequest(new { errors = failure.Messages }),
ErrorType.Conflict => Conflict(new { errors = failure.Messages }),
_ => StatusCode(500, new { errors = failure.Messages })
}
);
}
What makes it better?
It is self-documenting: The method signature clearly states “it returns user or failure” – no hidden surprises.
Compilation is implementation handling: Forget to handle failure case? compilation error. The compiler won’t let you ignore potential failures.
There is a clear intention: When you call a method to return
OneOfyou immediately know you need to handle both paths. It has no idea what exception might be thrown., Failure>
Whenever using an exception:
The goal is not to do away with exceptions entirely, but to save them for the really unusual situations when using them. OneOf For predictive, business login failures. You can still use exceptions in these scenarios:
Truly unexpected failures (out of memory, hardware failure)
Framework/library limitations that expect exceptions
Constructor failure (constructors cannot return result types)
Third Party Code Agreements
Other use cases
Use Case 1: Polymorphic Return Types (Without Inheritance)
When you need to return different types based on logic but don’t want to force inheritance:
public OneOf GetPaymentMethod(PaymentRequest request)
{
return request.Method switch
{
"card" => new CreditCardPayment(request.CardNumber, request.CVV),
"paypal" => new PayPalPayment(request.Email),
"crypto" => new CryptoPayment(request.WalletAddress),
_ => throw new ArgumentException("Unknown payment method")
};
}
var payment = GetPaymentMethod(request);
payment.Match(
card => ChargeCard(card),
paypal => ProcessPayPal(paypal),
crypto => ProcessCrypto(crypto)
);
Why it is better than inheritance:
A synthetic base class is not required
Each payment type can have completely different features
Clear, concise handling of each case
Easy to add new payment types (compiler will tell you everywhere to update)
Use case 2: Data-rich state machines
Representing different states in a workflow where each state holds different information.
public class Order
{
public OneOf Status { get; set; }
}
public record Pending(DateTime OrderedAt);
public record Processing(DateTime StartedAt, string WarehouseId);
public record Shipped(DateTime ShippedAt, string TrackingNumber, string Carrier);
public record Delivered(DateTime DeliveredAt, string SignedBy);
public record Cancelled(DateTime CancelledAt, string Reason);
var statusMessage = order.Status. Match(
pending => $"Order placed on {pending.OrderedAt:d}",
processing => $"Processing in warehouse {processing.WarehouseId}",
shipped => $"Shipped via {shipped.Carrier}, tracking: {shipped.TrackingNumber}",
delivered => $"Delivered on {delivered.DeliveredAt:d}, signed by {delivered.SignedBy}",
cancelled => $"Cancelled: {cancelled.Reason}"
);
Why not just use enum?
Enums only store state – they cannot carry additional data
with one,
ProcessingKnows which warehouse, etcShippedTracking number knows to offer more functionality and possible other logic to easily executeType-safe access to state-specific data
Impossible to access invalid data for a state (compiler prevents this)
Use Case 3: Multi-Channel Notifications
Sending notifications through different channels, each with different needs:
public record EmailNotification(string To, string Subject, string Body);
public record SmsNotification(string PhoneNumber, string Message);
public record PushNotification(string DeviceToken, string Title, string Body);
public record InAppNotification(int UserId, string Message);
public async Task SendNotification(
OneOf notification )
{
await notification.Match(
async email => await _emailService.SendAsync(email.To, email.Subject, email.Body),
async sms => await _smsService.SendAsync(sms.PhoneNumber, sms.Message),
async push => await _pushService.SendAsync(push.DeviceToken, push.Title, push.Body),
async inApp => await _notificationRepo.CreateAsync(inApp.UserId, inApp.Message)
);
}
await SendNotification(new EmailNotification("user@example.com", "Welcome", "Hello! "));
await SendNotification(new SmsNotification("+1234567890", "Your code is 123456"));
Advantages:
There can be a single, unified notification interface
Each channel has the exact parameters it needs
No optional/disabled properties for unrelated fields
Clear routing logic
Use Case 4: File Format Handling
Handling different file types and data formats:
public record CsvData(string() Lines);
public record JsonData(string Content);
public record ExcelData(IWorkbook Workbook);
public OneOf LoadDataFile(string path)
{
var extension = Path.GetExtension(path).ToLower();
return extension switch
{
". csv" => new CsvData(File.ReadAllLines(path)),
".json" => new JsonData(File.ReadAllText(path)),
".xlsx" => new ExcelData(LoadExcelFile(path)),
_ => throw new UnsupportedFileFormatException(extension)
};
}
var data = LoadDataFile(filePath);
var records = data.Match(
csv => ParseCsv(csv.Lines),
json => ParseJson(json.Content),
excel => ParseExcel(excel.Workbook)
);
It is best for:
APIs that offer multiple export formats
Import wizards that accept different file types
Configuration loaders supporting multiple formats
Key advantages of a
A flashes when you have:
Multiple valid return types that do not share a common base class
Different data formats for different scenarios
Type-safe branching is where you want the compiler to implement to handle all cases
Domain modeling where different states hold different information
Clear conclusions that should be part of the procedure signature
This is basically a way of saying “this method returns a or B or c“Forcing users to explicitly handle every possibility, in a type-safe way. This leads to more robust, self-documenting code that’s harder to abuse.”
The result
One brings the power of discrete unions to OFC#, enabling more expressive and type-safe code in many scenarios. Whether you’re building payment methods, order states, notification channels, or error handling models, Wino provides a clean, compiler-implementable way to handle multiple return types.
Start adding one to your projects, and you’ll find that your code becomes more deliberate, easier to maintain, and less error-prone.
As always, if you enjoy reading this article, feel free Get on Twitter.