An ASP.NET Core API often needs to return different kinds of results from the same endpoint. A payment operation might succeed, fail because the account has insufficient funds, or be rejected because the request contains invalid data. Representing every outcome with one large DTO can make the contract confusing, while returning loosely typed objects makes serialization and client-side handling harder to reason about.

A union type offers another approach: represent a value as one of a predefined set of alternatives. Instead of allowing arbitrary objects, the application defines the valid outcomes explicitly.

C# does not have a built-in general-purpose discriminated union syntax in the same way that some functional languages do. However, you can model a closed set of alternatives with records and a common base type, then use System.Text.Json polymorphism to serialize those alternatives as JSON.

This approach is useful for API responses, command results, validation outcomes, and domain operations where the possible result types are known in advance.

What Is a Union Type?

Consider an endpoint that creates an order. It can return a successful order, a validation failure, or a business-rule rejection.

A loosely typed implementation might look like this:

public async Task<object> CreateOrder(CreateOrderRequest request)
{
    // Business logic
}

Although this signature is flexible, it does not communicate which results callers should expect. Consumers must inspect the returned object or infer its shape from the response.

A union-style result makes the alternatives explicit:

public abstract record CreateOrderResult;

public sealed record OrderCreated(
    Guid OrderId,
    decimal Total) : CreateOrderResult;

public sealed record OrderValidationFailed(
    string Message) : CreateOrderResult;

public sealed record OrderRejected(
    string Reason) : CreateOrderResult;

The base type, CreateOrderResult, represents the complete set of outcomes. Each derived record represents one alternative.

This is a closed set by convention when the derived types are sealed and the application controls which implementations can be introduced. The base class itself is not automatically a compiler-enforced closed union, so keep the hierarchy and its supported alternatives under deliberate control.

Configure System.Text.Json for Polymorphic Serialization

System.Text.Json supports attribute-based polymorphic serialization and deserialization in .NET 7 and later. The JsonPolymorphic and JsonDerivedType attributes let you register supported derived types and assign stable JSON discriminators.

Start by annotating the result hierarchy:

using System.Text.Json.Serialization;

[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(OrderCreated), "created")]
[JsonDerivedType(typeof(OrderValidationFailed), "validation_failed")]
[JsonDerivedType(typeof(OrderRejected), "rejected")]
public abstract record CreateOrderResult;

public sealed record OrderCreated(
    Guid OrderId,
    decimal Total) : CreateOrderResult;

public sealed record OrderValidationFailed(
    string Message) : CreateOrderResult;

public sealed record OrderRejected(
    string Reason) : CreateOrderResult;

The kind property identifies which alternative appears in the JSON payload.

For example, an OrderCreated result can be serialized as:

{
  "kind": "created",
  "orderId": "d8b2c01e-ff31-4d0a-a1e2-3d6c6c6e8a10",
  "total": 149.99
}

A rejected order can use a different shape:

{
  "kind": "rejected",
  "reason": "The order exceeds the customer's credit limit."
}

The discriminator is more than a serialization detail. It gives API consumers a stable way to distinguish alternatives without guessing from the presence of particular properties.

Prefer explicit, meaningful discriminator values such as created and rejected rather than exposing CLR type names. Type names are implementation details and can change during refactoring.

Return Union Results from an ASP.NET Core Endpoint

Now use the result hierarchy in a minimal API endpoint.

The example below keeps the business operation separate from HTTP response mapping:

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/orders", (
    CreateOrderRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.CustomerId))
    {
        return Results.BadRequest(new
        {
            message = "CustomerId is required."
        });
    }

    CreateOrderResult result =
        request.Total <= 0
            ? new OrderValidationFailed(
                "Order total must be greater than zero.")
            : request.Total > 10_000
                ? new OrderRejected(
                    "The order exceeds the permitted limit.")
                : new OrderCreated(
                    Guid.NewGuid(),
                    request.Total);

    return result switch
    {
        OrderCreated created =>
            Results.Created(
                $"/orders/{created.OrderId}",
                created),

        OrderValidationFailed failure =>
            Results.BadRequest(failure),

        OrderRejected rejected =>
            Results.Conflict(rejected),

        _ => Results.Problem(
            "An unexpected order result occurred.")
    };
});

app.Run();

public sealed record CreateOrderRequest(
    string CustomerId,
    decimal Total);

The endpoint returns HTTP responses with different status codes according to the outcome. The union hierarchy describes the application-level result, while the switch expression determines the HTTP representation.

For a larger application, move the order-creation logic into an application service and keep the endpoint focused on translating results into HTTP responses.

This separation matters because a business rejection and an HTTP status code are related but not identical concepts. The same application result might be mapped differently by an HTTP API, a background worker, or a message consumer.

Keep the Result Model Separate from HTTP Responses

A common mistake is to put HTTP-specific concepts directly into every domain result.

For example, an OrderRejected record should not need to know about IResult, StatusCodes, or HTTP response headers if it is also used by non-HTTP application code.

Instead, let the application layer return a result:

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

Then map that result in the endpoint:

app.MapPost("/orders", async (
    CreateOrderRequest request,
    IOrderService orderService,
    CancellationToken cancellationToken) =>
{
    CreateOrderResult result =
        await orderService.CreateAsync(
            request,
            cancellationToken);

    return result switch
    {
        OrderCreated created =>
            Results.Created(
                $"/orders/{created.OrderId}",
                created),

        OrderValidationFailed failure =>
            Results.BadRequest(failure),

        OrderRejected rejected =>
            Results.Conflict(rejected),

        _ => Results.Problem()
    };
});

This pattern makes the application result reusable and keeps transport-specific decisions at the API boundary.

The example assumes that the service has been registered in dependency injection and implemented elsewhere.

Deserialize Union Types from JSON

Polymorphic serialization can also support deserialization when a discriminator is present and the derived types have been registered.

For example:

using System.Text.Json;

string json = """
{
  "kind": "rejected",
  "reason": "Credit limit exceeded."
}
""";

CreateOrderResult? result =
    JsonSerializer.Deserialize<CreateOrderResult>(json);

Console.WriteLine(result);

Because the base type declares the discriminator and supported derived types, the serializer can identify the corresponding result alternative.

This is useful when reading messages from a queue, processing persisted JSON, or consuming a polymorphic payload.

However, accepting union payloads from external clients requires deliberate validation. An unknown discriminator should not silently become a valid business outcome. Test malformed payloads, missing discriminators, unknown alternatives, and invalid property values against the serializer configuration you use.

Also remember that registering a derived type with JsonDerivedType does not validate the business rules represented by that type. Successful deserialization only means the JSON could be interpreted as the configured model.

Add Explicit Handling for Every Alternative

The main benefit of a union-style model is that callers can handle the known alternatives explicitly.

For example, an application service consumer can switch on the result:

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

        OrderValidationFailed failure =>
            $"Validation failed: {failure.Message}",

        OrderRejected rejected =>
            $"Order rejected: {rejected.Reason}",

        _ => throw new InvalidOperationException(
            "Unsupported order result.")
    };

This is clearer than repeatedly checking whether an arbitrary object is a particular DTO.

The fallback branch is still useful because a class hierarchy is not inherently a compiler-enforced closed union. If a new result type is introduced later, review all relevant switch expressions and update their handling.

For critical application logic, consider centralizing result-to-HTTP mapping rather than duplicating the same switch across multiple endpoints.

Document the API Contract with OpenAPI

Correct JSON serialization does not automatically guarantee that your generated OpenAPI schema describes every possible alternative as clearly as your application code does.

A union-style API response should document:

For example, a client should be able to distinguish created, validation_failed, and rejected using the documented kind field.

Depending on the ASP.NET Core version and the OpenAPI tooling in use, you may need additional schema configuration to represent the alternatives as a oneOf schema with a discriminator mapping. Verify the generated OpenAPI document rather than assuming that serializer attributes alone produce the desired client contract.

This is particularly important when generating typed clients, publishing SDKs, or maintaining APIs consumed by independently deployed services.

When to Use a Union Type Instead of a Single DTO

Union types are useful when the alternatives represent genuinely different outcomes with different data.

Use a union-style result when:

A single response DTO may be simpler when every response has essentially the same structure and only a few optional fields vary.

For example, if every response contains an order ID, a status, and an optional message, a conventional DTO may be easier to maintain. A union is most valuable when the alternatives have distinct meanings and distinct shapes.

Avoid creating a separate type for every minor variation. Too many alternatives can make the API harder to understand and increase the cost of maintaining client compatibility.

Common Pitfalls

Using object as the application result. This weakens the contract and makes it easier to introduce unsupported response shapes. Use an explicit result hierarchy when the alternatives are known.

Forgetting the discriminator. Without configured discriminator metadata, deserialization into the base type cannot reliably determine which registered alternative to construct.

Treating business errors as exceptions by default. Expected outcomes such as validation failures or credit-limit rejections can often be represented as result alternatives. Reserve exceptions for exceptional failures rather than using them as the only control-flow mechanism.

Returning inconsistent HTTP status codes. Define a consistent mapping between application outcomes and HTTP semantics. Validation failures, conflicts, and unexpected server errors should not all return the same status without a reason.

Assuming polymorphism enforces a closed set. The base class and its derived types still form an ordinary class hierarchy. Keep alternatives explicit and test the behavior when new types are introduced.

Ignoring generated schemas. Check the actual OpenAPI document and generated client behavior. The runtime JSON contract and the published API documentation should agree.

Summary

C# records and a shared base type provide a practical way to model union-style results when an API operation has a known set of outcomes. System.Text.Json polymorphism lets you serialize those alternatives with explicit discriminator values, while ASP.NET Core endpoints map application results to appropriate HTTP responses.

Keep business results separate from transport-specific response types, handle every alternative deliberately, and document the JSON contract through OpenAPI. For APIs with multiple meaningful response shapes, this approach creates a clearer contract than returning arbitrary objects or relying on loosely structured error messages.