Polymorphic JSON

Read and write class hierarchies with type discriminators, handle unknown or missing types, and keep the allowlist that makes it safe.

Syntax[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] [JsonDerivedType(typeof(CardPayment), "card")] [JsonDerivedType(typeof(BankTransferPayment), "bank-transfer")] public abstract record PaymentMethod(decimal Amount);

System.Text.Json serializes by the declared type. A List<PaymentMethod> holding a CardPayment writes only the base properties, and reading into an abstract PaymentMethod fails because the serializer cannot know which class to create. Polymorphism has to be declared.

Discriminators

Put [JsonPolymorphic] and one [JsonDerivedType(typeof(CardPayment), "card")] per subtype on the base type. When the declared type is the base, the serializer writes a discriminator property first (named $type unless you set TypeDiscriminatorPropertyName) and reads it back to pick the class. Discriminator values can be strings or integers. Rules that catch teams out:

  • The discriminator is only written when the declared type is the polymorphic base. TypedResults.Ok(card) with a CardPayment variable writes no type; declare the base type in the return or variable.
  • On read, the discriminator must be the first property. Otherwise the request fails, unless AllowOutOfOrderMetadataProperties = true, which buffers the object to find it. Payloads that pass through PostgreSQL jsonb or a JavaScript object built in another order often have it elsewhere.
  • An unknown discriminator throws JsonException: a 400. A missing discriminator on an abstract base throws NotSupportedException, which minimal APIs do not treat as a bad request: the client gets a 500. Catch it where the body is read, not in a global handler, because a write-side NotSupportedException is a server bug.
  • With a concrete base, IgnoreUnrecognizedTypeDiscriminators = true reads unknown types as the base, which suits consumers of events from a newer producer.
  • Serializing a subtype that is not registered throws by default; UnknownDerivedTypeHandling can fall back to the base or nearest registered ancestor.

Why the allowlist is the point

Newtonsoft.Json with TypeNameHandling.Auto or All reads a CLR type name from $type and instantiates it. An attacker who controls the JSON can name a type whose constructor or setters do something dangerous, a so-called gadget, and that class of bug has produced remote code execution in real products. System.Text.Json never loads a type by a name taken from the payload: only the derived types you registered can be created. Keep it that way. Do not accept JsonElement and call Type.GetType(body.Type) yourself, and do not use typeof(X).FullName as the discriminator: it leaks internals and renaming a class breaks every client.

Choosing a design

ApproachGood forCost
Separate endpoints (POST /payments/card)few subtypes, different validationmore routes
Attributes on the basetypes you ownbase must know every subtype
Contract modifier on DefaultJsonTypeInfoResolvertypes in a shared library you cannot annotateconfiguration in code

The OpenAPI generator describes a declared hierarchy as one schema per subtype plus a discriminator mapping, so generated clients get proper subtypes instead of one bag of optional fields.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Accept card and bank-transfer payments on one POST /orders/{id}/payments endpoint and let the serializer create the right record.
  • Consume inventory events from a newer producer and skip event types this service does not know instead of failing the whole batch.
  • Replace a Newtonsoft TypeNameHandling.Auto contract with a fixed list of discriminators during a security review.

More examples

Turning a missing discriminator into a 400

<code>AllowOutOfOrderMetadataProperties</code> fixes reordered keys at the cost of buffering each object. A body with no <code>type</code> at all still throws <code>NotSupportedException</code>. Catching it around <code>ReadFromJsonAsync</code>, which uses the same configured options, limits the 400 to request reading: a global exception handler matching every <code>NotSupportedException</code> from System.Text.Json would also turn write-side bugs, such as an unregistered derived type or missing <code>JsonTypeInfo</code>, into a 400 and hide the 500. <code>Accepts</code> keeps the body schema in OpenAPI.

Example · csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
    // Buffers the object until "type" is found, so key order no longer matters.
    options.SerializerOptions.AllowOutOfOrderMetadataProperties = true);

var app = builder.Build();

// The body is read inside the handler so only a failure while READING it becomes a 400.
// A missing discriminator on an abstract base throws NotSupportedException, not JsonException,
// and the same exception type on the write side is a server bug that must stay a 500.
app.MapPost("/orders/{orderId:int}/payments",
    async Task<Results<Accepted<PaymentMethod>, ProblemHttpResult>> (int orderId, HttpRequest request, CancellationToken ct) =>
    {
        if (!request.HasJsonContentType())
            return TypedResults.Problem(statusCode: StatusCodes.Status415UnsupportedMediaType, title: "Send application/json.");

        PaymentMethod? payment;
        try
        {
            payment = await request.ReadFromJsonAsync<PaymentMethod>(ct);
        }
        catch (Exception ex) when (ex is NotSupportedException or JsonException)
        {
            payment = null;
        }

        if (payment is null)
            return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Unknown payment shape",
                detail: "The body must include \"type\": \"card\" or \"bank-transfer\".");

        return TypedResults.Accepted($"/orders/{orderId}/payments", payment);
    })
    .Accepts<PaymentMethod>("application/json");

app.Run();

[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransferPayment), "bank-transfer")]
public abstract record PaymentMethod(decimal Amount);

public record CardPayment(decimal Amount, string Last4, string Brand) : PaymentMethod(Amount);
public record BankTransferPayment(decimal Amount, string Iban) : PaymentMethod(Amount);

Registering subtypes without attributes, tolerating new ones

A contract modifier sets the same polymorphism options the attributes would, so the base type does not need to reference its subtypes. Because <code>InventoryEvent</code> is concrete, <code>IgnoreUnrecognizedTypeDiscriminators</code> makes an event type this consumer has never seen deserialize as the base instead of throwing, and the consumer can log and skip it rather than poison its queue.

Example · csharp
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    TypeInfoResolver = new DefaultJsonTypeInfoResolver { Modifiers = { InventoryEventContract.AddDerivedTypes } },
};

InventoryEvent reserved = new StockReserved("KB-104", Quantity: 2, OrderId: 1001);
var json = JsonSerializer.Serialize(reserved, options);
// {"eventType":"stock.reserved","quantity":2,"orderId":1001,"sku":"KB-104"}

var known = JsonSerializer.Deserialize<InventoryEvent>(json, options);                                     // StockReserved
var newer = JsonSerializer.Deserialize<InventoryEvent>("""{"eventType":"stock.audited","sku":"KB-104"}""", options); // InventoryEvent
Console.WriteLine($"{known} / {newer}");

static class InventoryEventContract
{
    public static void AddDerivedTypes(JsonTypeInfo typeInfo)
    {
        if (typeInfo.Type != typeof(InventoryEvent))
            return;

        typeInfo.PolymorphismOptions = new JsonPolymorphismOptions
        {
            TypeDiscriminatorPropertyName = "eventType",
            IgnoreUnrecognizedTypeDiscriminators = true,
            DerivedTypes =
            {
                new JsonDerivedType(typeof(StockReserved), "stock.reserved"),
                new JsonDerivedType(typeof(StockReleased), "stock.released"),
            },
        };
    }
}

// These would normally live in a shared contracts package you cannot annotate.
public record InventoryEvent(string Sku);
public record StockReserved(string Sku, int Quantity, int OrderId) : InventoryEvent(Sku);
public record StockReleased(string Sku, int Quantity, int OrderId) : InventoryEvent(Sku);

Declare the base type where the response is built

If a handler returned <code>TypedResults.Ok(card)</code> where <code>card</code> is typed <code>CardPayment</code>, the response would lack <code>type</code> and a client that switches on it would fail. The typed union <code>Results&lt;Ok&lt;PaymentMethod&gt;, NotFound&gt;</code> pins the declared type, and the OpenAPI document then lists every subtype schema for the 200 response.

Example · csharp
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;

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

Dictionary<int, PaymentMethod> payments = new()
{
    [1] = new CardPayment(49.90m, "4242", "visa"),
    [2] = new BankTransferPayment(120.00m, "DE89370400440532013000"),
};

// Ok<PaymentMethod> fixes the declared type, so "type" is always written.
app.MapGet("/payments/{id:int}", Results<Ok<PaymentMethod>, NotFound> (int id) =>
    payments.TryGetValue(id, out var payment) ? TypedResults.Ok(payment) : TypedResults.NotFound());

app.Run();

[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransferPayment), "bank-transfer")]
public abstract record PaymentMethod(decimal Amount);

public record CardPayment(decimal Amount, string Last4, string Brand) : PaymentMethod(Amount);
public record BankTransferPayment(decimal Amount, string Iban) : PaymentMethod(Amount);

Discussion

  • Be the first to comment on this lesson.