Table of Contents

Custom converters

While using GenerateShapeAttribute is the simplest way to make an object graph serializable, some types need custom handling. In such cases, you can define and register your own JsonConverter<T>.

Define your own converter

Consider class Foo that cannot be serialized automatically.

Declare a class that derives from JsonConverter<T>:

[GenerateShape]
[JsonConverter(typeof(FooConverter))]
public partial class Foo
{
    public string? Name { get; set; }
}


public sealed class FooConverter : JsonConverter<Foo>
{
    public override void Write(ref JsonWriter writer, in Foo? value, SerializationContext context)
    {
        if (value is null)
        {
            writer.WriteNull();
            return;
        }

        context.DepthStep();
        writer.WriteStartObject();
        writer.WritePropertyName("name");
        writer.Write(value.Name?.ToUpperInvariant());
        writer.WriteEndObject();
    }

    public override Foo? Read(ref JsonReader reader, SerializationContext context)
    {
        if (reader.TryReadNull())
        {
            return null;
        }

        context.DepthStep();
        reader.ReadStartObject();
        string propertyName = reader.ReadRequiredString();
        reader.ReadNameSeparator();
        string? name = reader.ReadString();
        if (!reader.TryReadEndObject())
        {
            throw new JsonSerializationException("Expected the end of the object.");
        }

        if (!string.Equals(propertyName, "name", StringComparison.Ordinal))
        {
            throw new JsonSerializationException($"Unexpected property '{propertyName}'.")
            {
                Code = JsonSerializationException.ErrorCode.UnexpectedToken,
            };
        }

        return new Foo { Name = name?.ToLowerInvariant() };
    }
}

Use the scalar Write overloads to write strings, numbers, and booleans, and WriteRaw for already encoded JSON. JSON container methods such as WriteStartObject() remain explicit. JsonReader exposes typed scalar methods such as ReadInt32() as well as structural and raw-token methods.

Caution

It is imperative that each Write and Read method write and read exactly one JSON value.

If you have more than one field to serialize, wrap them in one JSON object or one JSON array. If the value may be null, handle that explicitly before reading or writing the surrounding structure.

Borrowing and retaining raw JSON

Use RawJson for a model member whose JSON should be decoded separately from its envelope. The built-in converter is automatic, preserves exact numeric and string token spelling, and always returns owned storage. No DOM registration is necessary.

public static string RoundTripRawEnvelope(string json)
{
    JsonSerializer serializer = new();
    RawEnvelope envelope = serializer.Deserialize<RawEnvelope>(json)!;
    // Deserialization owns these bytes, so they can be retained after the source is released.
    return serializer.Serialize(envelope);
}

[GenerateShape]
public partial class RawEnvelope
{
    public string? Method { get; set; }

    public RawJson Payload { get; set; }
}

JsonReader.ReadRaw(SerializationContext) validates and skips one value using the active depth and cancellation limits. Sequence-backed readers return a zero-copy borrowed slice; span-backed readers copy only the captured value into owned storage. Borrowed slices are valid only while their input buffers remain alive and unchanged. Call RawJson.ToOwned() before returning a pipe buffer or recycling application-owned memory. The method retains its owned copy when called on a mutable instance, so subsequent calls do not copy again.

JsonWriter.Write(RawJson) and the built-in converter validate exactly one strict JSON value before writing its UTF-8 bytes directly. This does not normalize numbers, decode and re-escape strings, or apply indentation within the raw payload. Comments, trailing commas, stream byte-order marks, multiple values, and empty or default payloads are rejected. Normal raw capture skips a leading stream byte-order mark before capturing the value, so it cannot introduce one inside an enclosing JSON container. For explicitly unvalidated output, use WriteRaw only with already trusted JSON.

Sequence-backed JsonReader instances parse the original segments rather than flattening the document. Only individual scalar tokens that cross segment boundaries may need temporary GC-owned storage. Peeking copies reader position, not input ownership; advancing a peek reader does not advance the original. For segmented input, an explicit peek allocates a small independent navigation cursor rather than copying any payload. Tentative UTF-8 string reads use allocation-free cursor checkpoints.

UTF-8 strings and token inspection

String reading and skipping enforce JSON quotes, escapes, four hexadecimal digits after \u, and escaping of control bytes. They deliberately do not validate Unicode well-formedness: the application receiving the data decides whether to validate it. CLR string reads use the runtime's UTF-8 replacement fallback (U+FFFD for invalid sequences), consistently across segment boundaries. Each \uXXXX escape contributes its UTF-16 code unit, including isolated surrogates; adjacent valid surrogate pairs retain their usual CLR representation. CLR string writes escape isolated surrogates instead of silently replacing user data. Raw JSON and unescaped UTF-8 string views preserve the original bytes, not a replacement-decoded approximation. Decoded UTF-8 string APIs preserve non-ASCII source bytes even around JSON escapes; an escaped isolated surrogate has no valid UTF-8 representation and uses replacement fallback in those byte results.

JsonReader.NextJsonType uses the same JsonValueKind vocabulary as the DOM. JsonReader.NextCode exposes the next significant UTF-8 byte, while JsonReader.IsNull and JsonReader.ReadNull() support explicit null handling. Inspection skips permitted insignificant input but does not validate an entire value.

TryReadStringSpan returns a borrowed, decoded UTF-8 view only for an unescaped, contiguous string. It returns false without advancing for null, escaped content, or content spanning segments; malformed strings still throw. Use JsonReader.ReadStringSequence() to handle all nullable strings, or JsonReader.ReadStringSpan() when null is not allowed. Both return decoded content, never JSON escape text, and allocate token-scoped storage only when necessary. Unescaped sequence slices retain the input's borrowed lifetime.

WriteString accepts decoded UTF-8 bytes and applies JSON escaping directly without an intermediate UTF-16 string or Unicode validation. Quotes, backslashes, and control bytes are escaped; all other bytes are transmitted unchanged, including invalid UTF-8. It accepts sequences even when a multi-byte character crosses a segment boundary. Do not pass an already-quoted or already-escaped JSON token to this method; use raw-value APIs for that contract.

Reader and writer Clone methods preserve syntax or formatting options but start at the root of their new input or destination. They do not inherit container state.

Writing directly into the output buffer

Use JsonWriter.GetSpan(int) and JsonWriter.Advance(int) to encode one complete, trusted UTF-8 JSON value directly into the writer's buffer. This is the direct-buffer equivalent of WriteRaw: it does not validate or escape the payload. Do not use it for fragments, property names, separators, empty payloads, or multiple values. For validated raw output, use RawJson instead.

[GenerateShape]
[JsonConverter(typeof(DirectNumberConverter))]
public partial class DirectNumber
{
    public long Value { get; set; }
}

public sealed class DirectNumberConverter : JsonConverter<DirectNumber>
{
    public override void Write(ref JsonWriter writer, in DirectNumber? value, SerializationContext context)
    {
        if (value is null)
        {
            writer.WriteNull();
            return;
        }

        // A signed 64-bit integer requires at most 20 UTF-8 bytes.
        Span<byte> destination = writer.GetSpan(20);
        if (!JsonPrimitives.TryWrite(destination, value.Value, out int bytesWritten))
        {
            throw new InvalidOperationException("The reserved number buffer was too small.");
        }

        writer.Advance(bytesWritten);
    }

    public override DirectNumber? Read(ref JsonReader reader, SerializationContext context)
        => reader.TryReadNull() ? null : new DirectNumber { Value = reader.ReadInt64() };
}

GetSpan(length) returns at least the requested payload capacity, excluding space reserved for enclosing indentation; zero requests a nonempty span. Reserving does not start a JSON value or change pending-property state, although growing the buffer may flush previously written bytes. Fill the returned span, then call Advance(bytesWritten) once with the length of the complete value, which may exceed the requested minimum but must fit the returned span. A positive advance applies normal value bookkeeping and enclosing indentation, without reformatting the payload's internal JSON. As with other writer APIs, callers write enclosing value separators explicitly.

Advance(0) cancels a reservation without writing an empty value or consuming pending-property state, and is harmless without a reservation. Negative or oversized arguments are rejected; an invalid advance does not consume the reservation or change byte counters. A new reservation replaces the old one and need not preserve its unadvanced contents, so rewrite the entire value after retrying with a larger span. The borrowed span expires on another reservation, a write, any advance, a flush, or returning the writer to JsonAsyncWriter. Never retain that span across those operations or copy a writer to share its reservation. A clone starts with no reservation, and async writer handoff preserves only advanced values and normal container state, not outstanding reservations.

Stateless scalar primitives

JsonPrimitives provides stateless reads and writes for null, booleans, every signed and unsigned integer width from 8 through 64 bits, finite single- and double-precision numbers, decimals, and exact number tokens. Scalar readers use TryRead overloads selected by the declared output type, matching the MessagePack API model; replaced type-specific method names are removed rather than retained as aliases. Scalar writers use the corresponding TryWrite overloads, including direct single-precision formatting rather than widening a float to double. Its number grammar is shared with the reader rather than maintained as a second parser. Primitive readers start exactly at the token; they do not skip whitespace, comments, or a BOM.

Pass isFinalBlock: false when more bytes can arrive. A complete-looking number or literal at the end of a non-final block returns InsufficientBuffer, since another byte could extend the number or invalidate the literal boundary. A delimiter or isFinalBlock: true establishes the boundary. Truncated prefixes in a final block are TokenMismatch, not a successful default value.

The nested JsonPrimitives.DecodeResult distinguishes success, wrong-type/malformed/out-of-range input, an empty buffer, and an incomplete valid prefix. Read tokenSize as bytes consumed on success, a minimum required source length on incomplete input, and zero on mismatch. An incomplete-input size is a lower bound, not a guarantee that a buffer of that length will complete the token: further bytes may extend its lexeme, and a delimiter or final-block indication is still necessary. Size hints are capped at the maximum span length, MaxValue. Decoded outputs are meaningful only on success. Writers leave the destination unchanged when it is too short and report its exact required length. Non-finite floating-point input and invalid raw number tokens fail with a required length of zero.

Floating-point reads follow the target framework's numeric parsing behavior, including legacy rounding limitations. Legacy floating-point output uses exponential notation with nine significant digits for single precision and seventeen for double precision instead of the old formatter's lossy defaults; negative zero is preserved. The span-based contract does not imply that every legacy runtime formatting helper is allocation-free.

The stateless surface deliberately does not yet include decoded strings, arbitrary raw JSON values, or container framing. Use the stateful reader and writer for those operations. In particular, strings need a caller-owned decoding destination and an explicit partial-escape/UTF-8 contract before a stateless API can be added safely.

Security considerations

Any custom converter that reads or writes nested JSON structures should call SerializationContext.DepthStep() on the provided SerializationContext before processing the structure.

This is important to prevent maliciously crafted payloads from causing a stack overflow or other denial-of-service issue. It also ensures the converter honors SerializationContext.CancellationToken, because DepthStep() performs the cancellation check.

Applications that have a legitimate need to exceed the default nesting limit can adjust SerializationContext.MaxDepth.

Delegating to sub-values

The GetConverter methods may be used to obtain converters for members of the type your converter is serializing or deserializing.

[GenerateShape]
public partial class Wrapper
{
    public Inner? Value { get; set; }
}

[GenerateShape]
public partial class Inner
{
    public string? Name { get; set; }
}

public sealed class WrapperConverter : JsonConverter<Wrapper>
{
    public override void Write(ref JsonWriter writer, in Wrapper? value, SerializationContext context)
    {
        if (value is null)
        {
            writer.WriteNull();
            return;
        }

        context.DepthStep();
        JsonConverter<Inner> innerConverter = context.GetConverter<Inner>();
        writer.WriteStartObject();
        writer.WritePropertyName("value");
        innerConverter.Write(ref writer, value.Value, context);
        writer.WriteEndObject();
    }

    public override Wrapper? Read(ref JsonReader reader, SerializationContext context)
    {
        if (reader.TryReadNull())
        {
            return null;
        }

        context.DepthStep();
        JsonConverter<Inner> innerConverter = context.GetConverter<Inner>();
        reader.ReadStartObject();
        reader.ReadRequiredString();
        reader.ReadNameSeparator();
        Inner? value = innerConverter.Read(ref reader, context);
        if (!reader.TryReadEndObject())
        {
            throw new JsonSerializationException("Expected the end of the object.");
        }

        return new Wrapper { Value = value };
    }
}

If the nested type does not have a directly generated shape, you can use a witness type decorated with GenerateShapeForAttribute<T> and request the converter using that shape.

Sharing state with converters

The indexer on SerializationContext lets applications pass custom state to converters. Configure it on the serializer's starting context:

private static readonly object TenantKey = new();

SerializationContext context = serializer.StartingContext;
context[TenantKey] = tenant;
serializer = serializer with { StartingContext = context };

A converter can retrieve the value with context[TenantKey]. Use a unique static readonly object key to avoid collisions with other state; assigning null removes the key. The context stores this state immutably, so changing a copied context does not mutate the original.

Per-call state

When the state varies from one call to the next (for example, per request or per tenant), pass a SerializationContext directly to an overload that accepts one instead of creating a new serializer for each call:

internal static readonly object TenantKey = new();

async Task SerializeForTenantAsync(JsonSerializer serializer, PipeWriter writer, Invoice invoice, string tenant, CancellationToken cancellationToken)
{
    // Start from the serializer's own context so its settings and state carry over.
    SerializationContext context = serializer.StartingContext;
    context[TenantKey] = tenant;
    context = context with { CancellationToken = cancellationToken };

    await serializer.SerializeAsync(writer, invoice, TypeShapeResolver.ResolveDynamicOrThrow<Invoice>(), context);
}

The converter reads the state from the context it is given:

// Fills in the tenant from per-call state when the value doesn't specify one.
public sealed class TenantIdConverter : JsonConverter<TenantId>
{
    public override void Write(ref JsonWriter writer, in TenantId? value, SerializationContext context)
    {
        if (value is null)
        {
            writer.WriteNull();
            return;
        }

        writer.Write(value.Value ?? context[PerCallStateSamples.TenantKey] as string);
    }

    public override TenantId? Read(ref JsonReader reader, SerializationContext context)
    {
        if (reader.TryReadNull())
        {
            return null;
        }

        return new TenantId { Value = reader.ReadString() ?? context[PerCallStateSamples.TenantKey] as string };
    }
}

Overloads that accept a SerializationContext are available for Serialize, Deserialize, SerializeObject, and DeserializeObject on JsonWriter and JsonReader, and for their asynchronous counterparts on pipes and streams. Keep these points in mind:

  • The supplied context is used instead of JsonSerializer.StartingContext. Its state is not merged with the serializer's, so start from serializer.StartingContext (as the sample does) when you want to keep the serializer's settings and state.
  • The operation is canceled by the context's SerializationContext.CancellationToken, so these overloads take no separate CancellationToken parameter.
  • Never pass the context a converter receives to a top-level serializer method. Doing so throws an ArgumentException. Use GetConverter to (de)serialize nested values instead.

Register your custom converter

There are three main ways to get Nerdbank.Json to use your converter.

Attribute approach

To use your converter wherever a type appears, apply JsonConverterAttribute to the type itself:

[GenerateShape]
[JsonConverter(typeof(FooConverter))]
public partial class Foo
{
    public string? Name { get; set; }
}

The converter's generic argument must match the attributed type; this example applies FooConverter : JsonConverter<Foo> to Foo itself. The attribute automatically asks PolyType to generate the associated converter constructor, so no separate AssociatedTypeShapeAttribute is required for attributed converters. Open generic converter types are supported on generic model types; generate shapes for the closed models through a witness type so their closed converter constructors are available under NativeAOT.

You may also apply JsonConverterAttribute to a property or constructor parameter to affect only that serialized slot:

[GenerateShape]
public partial class MemberAttributedContainer
{
    [JsonConverter(typeof(FooConverter))]
    public Foo? Value { get; set; }
}

[GenerateShape]
public partial class ParameterAttributedContainer([JsonConverter(typeof(FooConverter))] Foo? value)
{
    public Foo? Value { get; } = value;
}

Runtime registration

For precise runtime control, register a converter instance with JsonSerializer.Converters:

private void CustomConverterRegisteredAtRuntime()
{
    JsonSerializer serializer = new()
    {
        Converters = ConverterCollection.Create([new FooConverter()]),
    };
}

Open generic converter types may be registered with JsonSerializer.ConverterTypes:

private void CustomOpenGenericConverterRegisteredAtRuntime()
{
    JsonSerializer genericSerializer = new()
    {
        ConverterTypes = ConverterTypeCollection.Create([typeof(EnvelopeConverter<>)]),
    };
}

ConverterTypeCollection is immutable and supports collection expressions through its collection builder, as well as explicit calls to Create. For runtime registration without a converter attribute, associate an open generic converter with its model using AssociatedTypeShapeAttribute, and generate a shape for the closed model through a witness type. The Envelope<T> sample uses this pattern; applying GenerateShapeAttribute directly to an open generic model is not supported by PolyType.

Converter factories

When one converter pattern applies to many types, implement IJsonConverterFactory and register it with JsonSerializer.ConverterFactories.

private void CustomConverterFactory()
{
    JsonSerializer factorySerializer = new()
    {
        ConverterFactories = [new EnvelopeConverterFactory()],
    };
}

While a factory is creating a converter, it can use ConverterContext to resolve supporting converters. Both converter contexts expose the active shape provider and support typed shapes, untyped shapes, witness types, and runtime-type lookup. Factories implementing ITypeShapeFunc can use Invoke to enter a generic context without reflection-based generic activation. The EnvelopeConverterFactory sample uses reflective activation and is exercised only by the managed sample tests; prefer shape-based generic dispatch for NativeAOT.

Reference cycles in custom converters

Call ReportObjectConstructed after constructing an instance and before deserializing its members. When cycle-preserving deserialization is enabled, nested references can then resolve to that instance before the converter returns. Return the same instance that was reported. The call has no effect when cycle preservation is not active.

Targeted navigation in custom representations

Override SkipToPropertyValue and SkipToIndexValue when a custom representation remaps logical members, array indices, or dictionary keys. The corresponding asynchronous hooks return whether the selected value was found. Use the asynchronous reader's structural methods when entering containers so the serializer can drain the enclosing envelope correctly. These are public converter hooks; the internal navigation dispatchers are not extension points.

Version compatibility

Consider forward and backward compatibility in your converter. When reading JSON objects, continue reading every property even if some are unrecognized. When reading arrays, consume every element even if the current version only understands a prefix of them.

The simplest compatible pattern for object-like data is to switch on property names and skip unknown values.

Union types

When a custom converter is applied to the base type of a union, the built-in union wrapper still handles discriminator selection when the converter is attached to the concrete case type or base type itself. But when a converter is attached directly to a union-typed member or constructor parameter, that converter takes over the full representation for that slot, including any discriminator logic.

For more about the built-in union envelope, see unions.