When a method can return more than one kind of result, developers usually have to decide how to represent those different outcomes. A common approach is to return null when something is not available, throw an exception when an operation cannot be completed, or create a custom response class that contains several optional properties.
These approaches are familiar and they work, but they can make application code harder to understand. A developer calling the method may need to inspect several properties or guess what null means. An exception may also be used for something that is actually a normal business outcome.
Union types provide another way to model this situation. They allow a value to represent one of several defined types. Instead of hiding the possible outcomes inside a generic response, the type itself can describe what the caller should expect.
For example, imagine a customer lookup that can return either a customer or a reason why the customer cannot be returned:
Customer
OR
CustomerNotFound
The useful part here is not just the syntax. The application has a clear contract. Anyone using the method can see that these are two different outcomes and handle them accordingly.
What Is a Union Type?
A union type represents a value that can be one of several possible types.
A simple way to think about it is:
Result = Customer | CustomerNotFound
This means the result can contain a Customer or a CustomerNotFound value.
The idea is different from inheritance. With inheritance, several classes usually share a common base type:
public abstract class CustomerResult
{
}
public sealed class CustomerFound : CustomerResult
{
public Customer Customer { get; init; }
}
public sealed class CustomerNotFound : CustomerResult
{
}
The caller receives a CustomerResult and then needs to determine which concrete type it received.
A union-style design expresses the same idea more directly: the result has a known set of possible forms.
This kind of modeling is useful when the application already has clearly defined alternatives. The goal is not to introduce a new abstraction everywhere. The goal is to make existing business possibilities easier to represent in code.
Why Union Types Matter in Application Code
Consider a simple customer lookup:
Customer? GetCustomer(int id)
At first, this looks reasonable. The customer is returned when found, and null is returned when the customer does not exist.
The problem starts when the application has more than two meaningful outcomes.
For example, suppose the customer lookup can fail because:
- The customer does not exist.
- The customer is inactive.
- The customer is blocked.
- The request is invalid.
If all of these situations are represented by null, the caller loses important information.
The caller cannot tell whether:
null = customer does not exist
or:
null = customer is blocked
or:
null = request was invalid
A union-style result can make those differences explicit:
Customer
CustomerNotFound
CustomerInactive
CustomerBlocked
InvalidRequest
Now the application code has a clearer contract.
The developer does not need to guess what a missing value means. The possible outcomes are part of the design.
Union Types Are Not the Same as Nullable Types
A nullable type represents the possibility that a value is missing.
For example:
Customer?
usually means:
Customer
OR
null
That is useful when there are only two meaningful states.
A union can represent several different states:
Customer
CustomerNotFound
CustomerBlocked
ValidationError
These states contain different information.
This distinction becomes important in business applications because "not available" does not always mean the same thing.
For example, consider an order update operation.
The result might be:
OrderUpdated
OrderNotFound
OrderAlreadyShipped
ValidationError
Returning null for all failed cases would force the caller to find another way to determine what happened.
A union-style result can represent these outcomes directly.
Where Union Types Actually Help
Union types are most useful when a method has a small and stable set of expected outcomes.
Consider an authentication operation.
A login attempt might produce:
Authenticated
InvalidCredentials
AccountLocked
These are normal results of the authentication process. They are not necessarily unexpected system failures.
The same idea applies to payments:
PaymentSucceeded
PaymentDeclined
PaymentRequiresAction
Or order creation:
OrderCreated
ValidationFailed
InventoryUnavailable
In each example, the application already knows that multiple outcomes are possible.
That makes these situations good candidates for explicit result modeling.
The important point is that union types should describe meaningful application states. They should not become a general-purpose container for every possible problem that can happen in a system.
A Practical Customer Example
Imagine a service responsible for creating customers.
A simple implementation might look like this:
public async Task<Customer?> CreateCustomerAsync(
CreateCustomerRequest request)
{
if (await EmailExistsAsync(request.Email))
return null;
var customer = new Customer
{
Name = request.Name,
Email = request.Email
};
await SaveCustomerAsync(customer);
return customer;
}
There is a problem here.
The method returns null when the email already exists, but what if saving the customer fails because of a database problem?
The method signature does not tell us the difference.
A caller might see:
var customer = await service.CreateCustomerAsync(request);
if (customer == null)
{
// What exactly happened?
}
The developer now needs to look inside the implementation or documentation to understand what null means.
A more explicit result could describe the business outcomes:
CustomerCreated
EmailAlreadyRegistered
InvalidCustomer
The calling code can then handle each situation separately.
This makes the application easier to understand, especially when the service is used by multiple developers or multiple parts of the application.
Union Types and API Design
Union-style modeling can also be useful when building APIs.
Suppose an API creates an order.
The operation may produce:
OrderCreated
InvalidOrder
InventoryConflict
These application results can then be converted into appropriate HTTP responses.
For example:
return result switch
{
OrderCreated created =>
Results.Created(
$"/orders/{created.Order.Id}",
created.Order),
InvalidOrder invalid =>
Results.BadRequest(invalid.Errors),
InventoryConflict conflict =>
Results.Conflict(conflict.Message),
_ =>
Results.StatusCode(500)
};
The important architectural idea is that the application service is dealing with business outcomes, while the API layer is dealing with HTTP.
The business layer does not need to know that InventoryConflict should become HTTP 409.
The API layer makes that decision.
This separation keeps the business logic cleaner and makes it easier to reuse the same service from another type of application, such as a background worker or another internal service.
Why This Can Be Better Than Returning HTTP Results From Services
A common design problem in API applications is putting HTTP concepts directly into business services.
For example:
public async Task<IResult> CreateOrderAsync(...)
{
// Business logic
}
This makes the service dependent on ASP.NET Core.
Now imagine the same business operation needs to be used by a background worker.
The worker does not care about HTTP 400 or HTTP 409.
It cares about whether the order was created, rejected, or blocked because inventory was unavailable.
A business result is more useful:
CreateOrder()
↓
OrderCreated
InvalidOrder
InventoryUnavailable
The API can translate those results into HTTP responses.
The worker can handle them according to its own needs.
This is one of the practical reasons explicit result modeling can improve application architecture.
Union Types Can Reduce Exception Abuse
Exceptions are important in .NET applications, but they are not always the best way to represent expected business outcomes.
Consider this example:
try
{
await CreateOrderAsync(request);
}
catch (InventoryUnavailableException)
{
return Results.Conflict();
}
If inventory being unavailable is a normal possibility, using an exception for it can make the code harder to reason about.
The application is effectively using exceptions as part of its normal control flow.
A result-based approach can make the same situation explicit:
CreateOrder
↓
OrderCreated
OR
InventoryUnavailable
Exceptions can then remain available for unexpected failures such as:
Database connection failure
Unexpected infrastructure failure
Programming error
Corrupted application state
This gives the application a useful distinction:
Expected business outcome
→ Result
Unexpected system failure
→ Exception
That does not mean every exception should be replaced with a result. It means the application should distinguish between expected outcomes and unexpected failures.
Union Types and Pattern Matching
C# pattern matching works well with result types because the caller can inspect the actual result and handle each case.
For example:
return result switch
{
CustomerCreated created =>
Results.Ok(created.Customer),
EmailAlreadyRegistered =>
Results.Conflict(),
InvalidCustomer invalid =>
Results.BadRequest(invalid.Errors),
_ =>
Results.StatusCode(500)
};
This is easier to read than a method that returns object and requires several casts.
It also makes the business cases visible in one place.
When someone reviews the code, they can immediately see the outcomes that the endpoint knows how to handle.
This becomes particularly useful as an application grows because the possible states of an operation remain visible instead of being hidden behind generic types.
Union Types and Domain Modeling
Union-style modeling can be especially useful when working with business domains that have clearly defined states.
Consider a payment.
A payment may go through several states:
Pending
Authorized
Captured
Declined
Cancelled
Refunded
However, not every operation is valid in every state.
For example, capturing a payment that has already been cancelled should not be treated as a normal operation.
If the application represents payment states only with a string or integer, it becomes easier to accidentally write invalid logic:
if (payment.Status == "Cancelled")
{
// ...
}
The problem is not the if statement itself. The problem is that the application may have many places where developers need to remember which transitions are valid.
Explicit types can make these business rules easier to understand and maintain.
For larger systems, this becomes valuable because business rules are often spread across services, handlers, controllers, and background processes.
A well-designed result model gives those different parts of the application a common vocabulary.
Union Types and Validation
Validation is another area where multiple outcomes are common.
Consider a registration operation.
The result might be:
RegistrationSucceeded
InvalidEmail
WeakPassword
EmailAlreadyRegistered
Instead of returning a boolean:
bool RegisterUser(...)
the application can represent what actually happened.
A boolean gives only two possibilities:
true
false
The caller then needs additional information to understand why the operation returned false.
A meaningful result provides more information without requiring the caller to inspect internal implementation details.
This is especially useful when validation rules become more complex over time.
Union Types and Testing
Explicit result types can also make unit tests easier to understand.
Suppose an order service has three expected outcomes:
OrderCreated
ValidationFailed
InventoryUnavailable
The tests can directly verify each business outcome.
For example:
[Fact]
public async Task Returns_InventoryUnavailable_When_Stock_Is_Insufficient()
{
// Arrange
// Act
var result = await service.CreateOrderAsync(request);
// Assert
Assert.IsType<InventoryUnavailable>(result);
}
The test tells us exactly what the business rule is.
Compare that with testing an exception:
await Assert.ThrowsAsync<InventoryUnavailableException>(
() => service.CreateOrderAsync(request));
An exception-based test can also be perfectly valid, but it communicates something different. It says that the operation failed by throwing.
A result-based test says that the operation completed and produced a known business outcome.
That distinction can make a large test suite easier to read.
Union Types and Polymorphism Are Different
Union types and inheritance can look similar because both involve multiple types, but they solve different problems.
Polymorphism is generally useful when different implementations provide the same behavior.
For example:
public interface INotificationSender
{
Task SendAsync(Notification notification);
}
Different implementations might be:
EmailNotificationSender
SmsNotificationSender
PushNotificationSender
The application wants to use different implementations of the same behavior.
That is a good use case for an interface and polymorphism.
A union is more useful when the application needs to represent different possible forms or outcomes of a value.
The difference can be summarized simply:
Polymorphism
→ Different implementations of behavior
Union
→ Different possible forms or outcomes
Understanding this distinction prevents developers from using one abstraction where the other would be clearer.
Avoid Creating Huge Union Types
A union becomes difficult to maintain when it starts representing every possible problem in the system.
For example:
Success
ValidationError
NotFound
Conflict
Unauthorized
Forbidden
Timeout
DatabaseError
NetworkError
SerializationError
ConfigurationError
UnknownError
At this point, the result type is trying to represent both business outcomes and infrastructure failures.
That usually makes the design harder to understand.
A better approach is to separate the two categories.
For example:
Business result
↓
Success
ValidationError
Conflict
Unexpected infrastructure failure
↓
Exception
The business result stays focused on things the application expects and knows how to handle.
Unexpected infrastructure problems can continue through normal error handling and observability mechanisms.
Do Not Use Union Types Everywhere
A common mistake is assuming that union types are automatically better than normal return types.
They are not.
If a method simply retrieves a customer and either returns a customer or throws an unexpected exception, adding several result types may create unnecessary complexity.
For example, this can be perfectly reasonable:
public async Task<Customer> GetCustomerAsync(int id)
{
// Load customer
// Throw if an unexpected failure occurs
}
The problem starts when the application has several expected business outcomes that the caller must distinguish.
A useful question to ask is:
Does the caller need to know which expected outcome occurred?
If the answer is no, a union may not provide much value.
If the answer is yes, explicit result modeling becomes more attractive.
When Should You Use a Union Type?
A simple decision process can help.
First, ask whether the operation has multiple meaningful outcomes.
Does the operation have multiple outcomes?
↓
Yes
↓
Are those outcomes known and limited?
↓
Yes
↓
Does the caller need to distinguish them?
↓
Yes
↓
Consider a union-style result
For example:
CreateOrder()
↓
OrderCreated
ValidationFailed
InventoryUnavailable
This is a reasonable use case because each outcome means something different to the caller.
On the other hand:
GetConfiguration()
↓
Configuration
may not need a union at all.
The important thing is to model the domain problem, not to use a particular language feature simply because it exists.
Common Mistakes
Using null for too many meanings
If null can mean "not found," "not allowed," "invalid," and "database failure," the method contract is too vague.
Use a meaningful result model when the caller needs to distinguish those cases.
Using exceptions for normal business outcomes
If something happens regularly as part of normal application behavior, consider representing it as a result rather than an exception.
An inventory conflict is usually a business outcome.
A database connection failure is usually an infrastructure failure.
Those situations should not necessarily be modeled in the same way.
Putting HTTP status codes into business results
Avoid turning domain results into HTTP-specific objects too early.
The business layer should describe what happened.
The API layer should decide how that result becomes an HTTP response.
Creating too many result types
A result containing dozens of alternatives can become harder to understand than the original problem.
Keep the result focused on the outcomes the caller genuinely needs to handle.
Using unions when polymorphism is a better fit
If you have several implementations of the same behavior, an interface or abstract class may be more appropriate.
Union types are primarily useful for representing a known set of alternative values or outcomes.
A Practical Example of the Design
Imagine an order service with this operation:
CreateOrder()
The business rules say that three outcomes are important:
OrderCreated
ValidationFailed
InventoryUnavailable
The service should focus on producing one of those meaningful outcomes.
The API layer can then translate them:
OrderCreated
↓
HTTP 201
ValidationFailed
↓
HTTP 400
InventoryUnavailable
↓
HTTP 409
A background worker could handle the same result differently:
OrderCreated
↓
Continue processing
ValidationFailed
↓
Log and reject message
InventoryUnavailable
↓
Retry later
The business result stays the same, while each caller can decide how to handle it.
That is where this design becomes useful in real applications. The result represents the business operation, not the transport mechanism used to call it.
Summary
Union types are useful when an application operation has a small number of known and meaningful outcomes.
The biggest benefit is clarity. Instead of making developers guess what null means, inspect generic objects, or catch exceptions for normal business conditions, the application can make those outcomes explicit.
For example:
Customer
OR
CustomerNotFound
or:
OrderCreated
OR
ValidationFailed
OR
InventoryUnavailable
This approach works particularly well in service layers, domain logic, API boundaries, validation, and business workflows where callers need to distinguish between different expected results.
It is not a replacement for every return type, exception, interface, or class hierarchy. A simple return type is still the right choice when there is one clear outcome. Exceptions are still appropriate for unexpected failures. Interfaces and polymorphism are still useful when different implementations provide the same behavior.
The best use of union-style modeling is therefore fairly simple: use it when the operation naturally has several known outcomes and making those outcomes explicit will make the code easier to understand, test, and maintain.
Join the conversation! Your thoughts help the community grow.