A JSON field that is sometimes a number and sometimes a string looks harmless until it reaches an ASP.NET Core boundary. Model it as object, and every consumer can send almost anything. Model it as two unrelated DTOs, and the endpoint contract becomes awkward. C# 15 union API contracts give the value one name and a fixed set of cases, but the JSON wire format still needs deliberate design.

This guide builds an IntOrString API contract for .NET 11, shows where deserialization becomes ambiguous, and adds checks for exhaustive handling and OpenAPI compatibility. The goal is not merely to adopt new syntax. It is to keep an existing discriminator-free payload predictable before it reaches production clients.

Start C# 15 union API contracts with the wire format

Suppose an availability policy already uses the Kubernetes-style convention of accepting either an absolute count or a percentage. The valid JSON values are 2 and "25%". A Boolean, object, or arbitrary array must fail. That is a wire-level rule, not a presentation detail.

object cannot express that rule. It moves validation into late runtime branches and makes generated API descriptions vague. A native union puts the alternatives in the signature while preserving the existing JSON shapes: the active case is written directly, without an envelope or $type discriminator.

public union IntOrString(int, string);

IntOrString absolute = 2;
IntOrString percentage = "25%";

This is a good fit only when the two representations are intentional. If a string such as "2" is merely accidental input for a numeric field, fix the producer or reject it. A union should describe a real contract, not preserve data drift.

Declare a fixed union and handle every case

A union has a closed list of case types. Pattern matching works with those types, and the compiler can check an exhaustive switch. That changes contract evolution from a silent runtime surprise into a build-time signal.

static string Describe(IntOrString value) => value switch
{
    int count when count >= 0 => $"{count} instances",
    int => throw new ArgumentOutOfRangeException(
        nameof(value),
        "An absolute value cannot be negative."),
    string percentage => ParsePercentage(percentage),
};

static string ParsePercentage(string value)
{
    if (!value.EndsWith('%') ||
        !int.TryParse(value[..^1], out var amount) ||
        amount is < 0 or > 100)
    {
        throw new ArgumentException(
            "A percentage must be between 0% and 100%.",
            nameof(value));
    }

    return $"{amount}%";
}

The first layer decides which representation is active. The guards then enforce business rules inside each representation. These concerns should remain separate: the union prevents an unsupported type, while application validation rejects a supported type carrying an invalid value.

If a third case is later added and the switch is not updated, the compiler reports CS8509. Treat that warning as an error in CI for contract-owning projects. It forces every handler to acknowledge the new case before the change ships.

Separate unambiguous JSON from ambiguous JSON

Writing a union is straightforward because the runtime already knows the active case. Reading is harder: System.Text.Json must choose a case from the incoming token. A Boolean and a string are naturally distinct JSON token types. Two records are both objects, so their opening token is identical.

public record Cat(string Name, string Coat);
public record Dog(string Name, string Breed);

[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Pet(Cat, Dog);

The structural classifier examines property names to distinguish Cat from Dog. That preserves a discriminator-free format, but it has costs. Classification work grows with the payload, and a later property rename can make old JSON ambiguous. Add fixtures for overlapping and missing properties; do not assume the classifier will infer product intent.

Web JSON options add another edge case. ASP.NET Core can read numbers from JSON strings, so an input union of int and string may require explicit custom classification even though the JSON token is a string. Output-only endpoints do not have that read-path ambiguity. Decide whether the union crosses an input boundary before calling the contract complete.

Choose a union, closed hierarchy, or open hierarchy

Use a union when alternatives are unrelated types, include primitives, or must preserve an established discriminator-free shape. Use a closed hierarchy when you own related object types and can add a discriminator. Keep a hierarchy open only when other assemblies are expected to extend it.

[JsonPolymorphic(InferClosedTypePolymorphism = true)]
public closed record class PaymentEvent(string PaymentId);

public sealed record class PaymentAuthorized(
    string PaymentId,
    decimal Amount) : PaymentEvent(PaymentId);

public sealed record class PaymentFailed(
    string PaymentId,
    string Reason) : PaymentEvent(PaymentId);

The closed hierarchy keeps shared behavior and tells C# that the known derived types form a complete set. With inferred closed-type polymorphism enabled, JSON includes a discriminator such as "$type":"PaymentAuthorized". That is usually the safer choice for a new object contract because the payload identifies its own case.

The tradeoff is compatibility. Adding a discriminator changes an existing wire format. Conversely, keeping object cases discriminator-free makes structural ambiguity your responsibility. Write the JSON contract down first, then select the C# model that matches it.

Expose the contract through ASP.NET Core

Minimal APIs can use unions in request bodies and return values. Runtime request-delegate creation and the source-generated Request Delegate Generator follow the same behavior. Keep binding at a JSON body boundary; route values, query strings, headers, and form fields do not use the union converter.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

var app = builder.Build();

app.MapOpenApi();

app.MapGet(
    "/deployments/{name}/max-unavailable",
    (string name) =>
        TypedResults.Ok(DeploymentPolicies.Get(name)));

app.MapPost(
    "/flags",
    (BoolOrString flag) => TypedResults.Ok(flag));

app.Run();

public union BoolOrString(bool, string);

The return type of DeploymentPolicies.Get should be IntOrString. The separate BoolOrString request demonstrates an unambiguous read path because Boolean and string tokens differ. Avoid presenting the int|string example as a safe request body until its string-number behavior has an explicit classifier and tests.

SignalR supports unions only when it uses JsonHubProtocol; MessagePack and Newtonsoft.Json hub protocols do not. In Blazor, direct in-process component parameters require no serialization, while JavaScript interop, persisted component state, and prerendered parameters follow the same System.Text.Json rules. Inventory every serialization boundary, not merely HTTP endpoints.

Verify JSON and OpenAPI in CI

ASP.NET Core describes a union with OpenAPI anyOf, one schema per case. There is no union discriminator. Client generators differ in how well they model anyOf, so generated output is part of the compatibility surface.

public sealed class UnionContractTests
{
    [Theory]
    [InlineData("2")]
    [InlineData("\"25%\"")]
    public void IntOrString_round_trips_supported_shapes(string json)
    {
        var value = JsonSerializer.Deserialize<IntOrString>(json);
        var roundTrip = JsonSerializer.Serialize(value);

        Assert.Equal(json, roundTrip);
    }

    [Fact]
    public void IntOrString_rejects_an_unsupported_shape()
    {
        Assert.Throws<JsonException>(() =>
            JsonSerializer.Deserialize<IntOrString>("true"));
    }
}

Run fixtures for every accepted case, unsupported token types, null behavior, and ambiguous object shapes. For input contracts, use the same web options as the application rather than default serializer options so string-number handling cannot hide.

dotnet build -warnaserror
dotnet test --configuration Release
dotnet build Api.csproj +  -p:OpenApiGenerateDocuments=true +  -p:OpenApiDocumentsDirectory=artifacts/openapi

git diff --exit-code -- artifacts/openapi

The final command is useful only when the generated document is committed or compared with an approved baseline. Review the diff for the exact anyOf cases and run at least one real client generator used by your consumers. A syntactically valid OpenAPI document can still produce an inconvenient or breaking client model.

Roll out without surprising clients

  • Freeze representative JSON fixtures before replacing an existing wrapper or object model.
  • Generate the OpenAPI document before and after the change and review anyOf case order, nullability, and component references.
  • Regenerate one supported client and compile its contract tests.
  • Deploy an output-only union first when the input classifier is not settled.
  • Track deserialization failures by endpoint without logging sensitive payloads.

Rollback should restore the previous API model and OpenAPI baseline together. Reverting only server code while leaving regenerated clients or a published schema behind creates a split contract. If a new union case is necessary, treat it as a versioned compatibility decision: exhaustive server switches will warn, but external clients do not receive that compiler protection.

C# 15 union API contracts remove a great deal of wrapper code, yet they do not remove contract design. The production-safe sequence is simple: define the JSON shapes, make ambiguous reads explicit, verify anyOf with the clients you support, and let exhaustive matching stop unhandled cases at build time.

References

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
100% Free SEO Tools - Tool Kits PRO