---
title: "C# 15 Union Types for API Results: Examples, Trade-Offs, and Migration"
published_at: "2026-10-21T14:17:46+00:00"
modified_at: "2026-10-21T14:44:06+00:00"
url: "https://www.syncfusion.com/blogs/post/csharp-15-union-types-api-results"
excerpt: "Learn how C# 15 union types simplify ASP.NET Core API results, compare with existing C# patterns, and help you decide when to adopt them."
taxonomy_category:
  - ".NET development"
  - "ASP.NET Core"
  - "C#"
  - "Software Architecture"
  - "Web Platform APIs"
  - "What's new"
taxonomy_post_tag:
  - ".NET Development"
  - "ASP.NET Core"
  - "C#"
  - "Microsoft"
  - "Software Development"
  - "Web API"
---

# C# 15 Union Types for API Results: Examples, Trade-Offs, and Migration

[Arunachalam Kandasamy Raja](https://www.syncfusion.com/blogs/author/arunachalam-kandasamy-raja)

![C# 15 Union Types for API Results: Examples, Trade-Offs, and Migration](https://www.syncfusion.com/blogs/wp-content/uploads/2026/10/C-15-Union-Types-for-API-Results-Examples-Trade-Offs-and-Migration.jpg)


**TL;DR:** C# 15 union types let you represent a fixed set of possible outcomes, making API results clearer and helping the compiler identify unhandled cases. Learn how to use them in ASP.NET Core, compare them with Result<T>, interfaces, and exceptions, and understand the trade-offs, serialization limits, and migration considerations before adopting them.

Imagine building an e-commerce API that creates orders. An order might be created successfully, rejected because the quantity is invalid, or failed because the requested product cannot be found.

Your code needs to handle each outcome correctly. A return type that does not clearly represent these possibilities can leave callers checking flags, interpreting ambiguous values, or using exceptions for expected business outcomes.

C# 15 introduces union types, which let you define a fixed set of possible types for a value. The compiler can then help identify cases that a switch expression does not handle.

Union types are a preview feature in the .NET 11 development cycle. Their syntax and supporting behavior may change before the final release, so verify the latest [C# 15 documentation](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15)
 before adopting them.

## What is C# 15 union types?

A union type represents a value that can be one of several predefined types. Unlike an interface or base class, its case types do not need to share an inheritance hierarchy.

For example, an order-processing operation can produce three outcomes:

- The order is created successfully.
- The request is invalid.
- The requested product cannot be found.

You can represent these outcomes using records and a union:

C#

```
public sealed record OrderCreated(Guid OrderId);
public sealed record InvalidOrder(string Message);
public sealed record ProductNotFound(string ProductCode);

public union CreateOrderResult(
    OrderCreated,
    InvalidOrder,
    ProductNotFound);
```

The `CreateOrderResult` union can contain an `OrderCreated`, `InvalidOrder`, or `ProductNotFound` value.

You can assign a case directly to the union because the compiler provides implicit conversions from its declared case types:

C#

```
CreateOrderResult result = new OrderCreated(Guid.NewGuid());
```

If more than one case type is equally applicable to a source value, the conversion is ambiguous and the compiler reports an error. Use distinct case types or explicitly create the intended case to avoid ambiguity.

## Handle every case with pattern matching

One of the most useful features of union types is exhaustive pattern matching.

C#

```
static string DescribeResult(CreateOrderResult result) =>
    result switch
    {
        OrderCreated created =>
            $"Order {created.OrderId} was created.",

        InvalidOrder invalid =>
            $"Invalid order: {invalid.Message}",

        ProductNotFound notFound =>
            $"Product {notFound.ProductCode} was not found."
    };
```

The switch expression handles every declared case. If you add another case to `CreateOrderResult`, the compiler can report an incomplete switch expression with warning `CS8509`.

You can make this warning fail the build by adding the following configuration to your project file:

XML

```
<PropertyGroup>
   <WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
</PropertyGroup>
```

This is useful when an operation has a known set of outcomes and every caller must consider each possibility. For details, see Microsoft’s [C# union types reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union)
.

## Using C# 15 union types in ASP.NET Core APIs

Union types can separate business outcomes from HTTP response handling. The application service determines what happened, while the controller maps that outcome to an appropriate HTTP response.

Let’s implement a simplified order-creation API.

### Step 1: Define the request and service

Use the `CreateOrderResult` union from the previous section.

First, define the request and service interface:

C#

```
public sealed record CreateOrderRequest(
    string ProductCode,
    int Quantity);

public interface IOrderService
{
    Task<CreateOrderResult> CreateAsync(
        CreateOrderRequest request,
        CancellationToken cancellationToken);
}
```

The service returns one of the declared result types:

C#

```
public sealed class OrderService : IOrderService
{
    public Task<CreateOrderResult> CreateAsync(
        CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        if (request.Quantity <= 0)
        {
            return Task.FromResult<CreateOrderResult>((
                new InvalidOrder(
                    "Quantity must be greater than zero."));
        }

        // A real application would query its product catalog here.
        if (!ProductExists(request.ProductCode))
        {
            return Task.FromResult<CreateOrderResult>(
                new ProductNotFound(request.ProductCode));
        }

        return Task.FromResult<CreateOrderResult>(
            new OrderCreated(Guid.NewGuid()));
    }

    private static bool ProductExists(string productCode) =>
        productCode.Equals(
            "P100",
            StringComparison.OrdinalIgnoreCase);
}
```

This simplified service validates the quantity, checks product availability, and returns the corresponding result. In a production application, replace the example product check and generated order ID with actual catalog and order-creation logic.

Register the service with dependency injection:

C#

```
builder.Services.AddScoped<IOrderService, OrderService>();
```

### Step 2: Map the results to HTTP responses

The controller handles each result and returns the appropriate HTTP status code.

C#

```
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpPost]
    [ProducesResponseType(
        typeof(OrderCreated),
        StatusCodes.Status201Created)]
    [ProducesResponseType(
        typeof(ProblemDetails),
        StatusCodes.Status400BadRequest)]
    [ProducesResponseType(
        typeof(ProblemDetails),
        StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Create(
        CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        CreateOrderResult result =
            await _orderService.CreateAsync(
                request,
                cancellationToken);

        return result switch
        {
            OrderCreated created =>
                CreatedAtAction(
                    nameof(GetById),
                    new { id = created.OrderId },
                    created),

            InvalidOrder invalid =>
                BadRequest(new ProblemDetails
                {
                    Title = "Invalid order",
                    Detail = invalid.Message,
                    Status = StatusCodes.Status400BadRequest,
                    Extensions =
                    {
                        ["code"] = "invalid_order"
                    }
                }),

            ProductNotFound notFound =>
                NotFound(new ProblemDetails
                {
                    Title = "Product not found",
                    Detail =
                        $"Product {notFound.ProductCode} was not found.",
                    Status = StatusCodes.Status404NotFound,
                    Extensions =
                    {
                        ["code"] = "product_not_found",
                        ["productCode"] = notFound.ProductCode
                    }
                })
        };
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    public IActionResult GetById(Guid id)
    {
        return Ok(new { OrderId = id });
    }
}
```

The example maps each business outcome to an HTTP response:

| Business outcome | HTTP response |
| --- | --- |
| Order created | 201 Created |
| Invalid order | 400 Bad Request |
| Product not found | 404 Not Found |

The service does not need to know about HTTP status codes. The controller handles that responsibility and returns `ProblemDetails` for the error responses.

The example’s status codes and response bodies are illustrative. An actual API should follow its documented contract and established error-handling conventions.

The key benefit is that the controller handles each declared result explicitly. If you add a case such as `InsufficientInventory`, the compiler can help identify switch expressions that need updating.

## Union types vs. existing C# approaches

Union types are not a replacement for every result-handling pattern. The right choice depends on the problem your application needs to solve.

| Approach | Best suited for | Main consideration |
| --- | --- | --- |
| Nullable return value | A value may be absent, and that is the only alternative | Does not describe several distinct outcomes |
| Exceptions | Unexpected failures that cannot be handled locally | Can obscure expected business outcomes if overused |
| Result<T> | A consistent success-or-failure pattern across operations | May need additional modeling for multiple distinct outcomes |
| OneOf or a similar library | Union-like behavior before native unions are available | Adds a dependency and library-specific conventions |
| Interfaces or abstract records | Types that share behavior or an extensible contract | Do not inherently provide the same closed-case checking |
| C# 15 union types | A value must be one of a fixed set of alternatives | Requires preview-feature adoption and compatibility evaluation |

## Union types vs. Result<T>

A `Result<T>` abstraction can be a good choice when an application needs consistent error handling, validation details, or helper methods for chaining operations.

Union types are especially useful when an operation has several distinct outcomes that callers should handle separately.

For example, an inventory operation might return `InventoryReserved`, `ProductNotFound`, or `InsufficientInventory`. Each case communicates a specific outcome.

If your existing `Result<T>` implementation already handles the problem well, switching to union types simply because they are newer may not justify the migration cost.

## Union types vs. interfaces

Interfaces are appropriate when different implementations must share a contract and new implementations may be introduced by other parts of the application.

Union types are better suited to a deliberately closed set of alternatives. The compiler knows which cases belong to the union and can help verify that all cases are handled.

A closed class hierarchy may also be suitable when the cases are related classes that naturally share a base type. Consider a union when the alternatives are unrelated types or should not be forced into a common inheritance hierarchy.

## When should you use union types?

Union types are worth evaluating when an application has a known set of outcomes, and every caller should consider them deliberately.

Consider them when:

- An API operation returns several expected business outcomes.
- A domain model has a fixed set of result types.
- Existing result objects permit confusing or contradictory combinations.
- You want compiler feedback when new cases are added.
- Your team controls the relevant consumers and can manage compatibility changes.

An existing approach may be more suitable when:

- Your production environment cannot use preview features.
- External consumers need to introduce their own implementations.
- An established result abstraction already meets your needs.
- A public library cannot easily manage source-compatibility changes.
- A nullable value or simpler result type is sufficient.

## Consider performance for value types

Compiler-generated union declarations use an `object?` reference to hold the active value. As a result, value-type cases are boxed in the standard generated representation.

This does not automatically make union types too slow. However, if your union contains value types and is used in a performance-sensitive area, measure the actual workload. The C# union model also supports custom implementations that can avoid boxing by providing an appropriate access pattern.

See Microsoft’s [union types reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/union)
 for implementation details.

## What should you consider before migrating?

Changing a method’s return type is only one part of adopting union types. Callers, public contracts, and serialized data may also be affected.

Follow these steps to plan the migration.

1. **Review the existing result model.** Identify methods that return custom result classes, callers that inspect success flags, and code that depends on existing error properties.
2. **Define meaningful cases.** Create a separate type for each outcome that carries distinct information. Avoid cases that represent the same business meaning under different names.
3. **Migrate internal consumers first.** Introduce the union in a controlled area and update the relevant switch expressions. Keep existing HTTP response formats unchanged if clients are not intended to see a contract change.
4. **Test compatibility before removing the old model.** Check response status codes, response bodies, OpenAPI descriptions, generated clients, and any persisted or transmitted data that depends on the old result type.

Adding a union case can require existing consumers to change their code. For public APIs and libraries, review this impact before releasing the new case.

## A note on JSON serialization

Starting with .NET 11, `System.Text.Json` supports serialization and deserialization of C# union types. The serializer writes the active case and must identify the correct case when reading JSON.

For example:

C#

```
[JsonUnion(
    TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union CreateOrderResult(
    OrderCreated,
    InvalidOrder,
    ProductNotFound);
```

The structural classifier can distinguish object cases using their root-level properties. For this to work, the case shapes must be sufficiently distinct.

For example, two cases with overlapping property structures may be ambiguous. Likewise, `Guid` and `string` both use JSON strings, so the default classifier cannot distinguish them reliably. A custom classifier may be needed when the default rules cannot select one case.

With `JsonSerializerOptions.Web`, an int may also be read from a JSON string. This can make `int` and `string` union cases ambiguous during deserialization, requiring a custom classifier.

Keep these points in mind:

- JSON serialization does not automatically guarantee that every union can be deserialized unambiguously.
- Changes to property names or required properties may affect how a structural classifier identifies a case.
- If you control the JSON contract and need a stable discriminator, a closed hierarchy with explicit polymorphic serialization may be more appropriate.

In the ASP.NET Core example above, the controller maps the result into an HTTP response rather than returning the union itself. Therefore, the union is an application-layer result and does not automatically become the API’s JSON contract.

For the supported classifiers and limitations, see Microsoft’s [System.Text.Json union serialization documentation](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/union-types)
.

## Frequently Asked Questions

Can a C# 15 union contain null?Yes. A union can include a nullable case type, such as `string?`. The default value of a generated union can also have no active case, so some switch expressions may need to account for `null`. If `null` represents a meaningful business outcome, a named case type usually communicates the intent more clearly.

Can a union contain value types such as int or Guid?Yes. Value types can be union cases. The standard compiler-generated representation stores the value through an `object?` reference, so value-type cases are boxed. Consider performance testing if the union is used in an allocation-sensitive part of the application.

Do C# 15 union types require a NuGet package?Native union declarations do not require a third-party union package. They do require a compatible C# compiler and .NET SDK. Check the current preview requirements before creating a project.

Can union types replace enums?Not in every situation. Enums work well for simple named states. Union types are more useful when each alternative has a different type, carries different data, or requires different handling.

Are C# 15 union types ready for production?Union types remain a preview feature in the C# 15 and .NET 11 development cycle. Their syntax and supporting behavior may change before the final release.

Try them in a controlled project first. Verify the SDK and compiler versions, and test serialization, API contracts, tooling, and performance before considering production adoption.

## Conclusion

C# 15 union types provide a way to represent a fixed set of possible outcomes and help developers identify unhandled cases during compilation.

For ASP.NET Core APIs, they can make business results explicit while allowing controllers to map those results to HTTP responses. They can also represent closed sets of domain states without forcing unrelated types into a shared inheritance hierarchy.

Union types are not automatically better than `Result<T>`, interfaces, exceptions, or existing libraries. Their value depends on whether the application benefits from a closed set of alternatives and compiler-checked handling.

Before adopting them, evaluate the feature in a controlled project, verify the current preview behavior, and test compatibility with your API contracts. Choose union types when they solve a real modeling problem, not simply because they are a new C# feature.

## Related Blogs



[Should C# Developers Switch to Blazor Full Stack in .NET 10?](https://www.syncfusion.com/blogs/post/blazor-full-stack-dotnet-10)



[Mono vs CoreCLR in .NET MAUI: Performance, Compatibility, and What Changes in .NET 11](https://www.syncfusion.com/blogs/post/mono-vs-coreclr-dotnet-maui)



[8 .NET MAUI Performance Anti-Patterns That Make Apps Feel Slow](https://www.syncfusion.com/blogs/post/dotnet-maui-performance-anti-patterns)



[Reduce XAML Boilerplate with .NET MAUI Global/Implicit Namespace + Source Generator](https://www.syncfusion.com/blogs/post/reduce-xaml-boilerplate-code-in-maui)
