JSON
The ShapeShift.Json package maps PolyType contracts to UTF-8 JSON without
delegating object mapping to System.Text.Json.JsonSerializer. It is
NativeAOT-compatible when used with source-generated PolyType shapes.
Install the ShapeShift.Json package and annotate serialized root types with
PolyType's GenerateShapeAttribute:
var serializer = new JsonSerializer
{
PropertyNamingPolicy = ShapeShiftNamingPolicy.CamelCase,
Indented = true,
};
var person = new Person("Ada", ["mathematics", "programming"]);
string json = serializer.Serialize(person);
Person? copy = serializer.Deserialize<Person>(json);
JsonSerializer supports:
- UTF-8 input and output.
- JSON strings.
- Caller-owned
IBufferWriter<byte>destinations. - Incremental, non-buffering asynchronous I/O for
Stream,PipeWriter, andPipeReader. - Optional indentation.
- Configurable comment and trailing-comma handling.
- Explicit opt-in support for
"NaN","Infinity", and"-Infinity". - All shared ShapeShift converters and policies, including naming policies, generated surrogates, attributed unions, default-value omission, and strict duplicate/required-member validation.
- The format-neutral
ShapeShiftValuetree for untyped JSON. - NativeAOT-safe
JsonElement,JsonDocument, andJsonNodepass-through converters. - Unknown-property capture and round trips through
ShapeShiftExtensionDataAttribute.
String escaping
ShapeShift emits the minimal escaping required by RFC 8259: quotation marks,
reverse solidus (\), and the U+0000 through U+001F control characters are
escaped. Other valid Unicode characters are written directly as UTF-8,
including non-ASCII, HTML-sensitive, and U+2028/U+2029 characters.
Consequently, JSON output is suitable as JSON data but must not be inserted
unescaped into an HTML document or <script> element. Use an HTML-aware
encoding layer when embedding JSON in HTML. JsonSerializer applies this
policy automatically. Code that constructs a Utf8JsonWriter and
JsonEncoder directly can select the same policy by assigning
JsonEncoder.Rfc8259StringEncoder to JsonWriterOptions.Encoder.
Wire representations
JSON objects require string property names. Dictionaries with string keys are
therefore encoded as JSON objects. Dictionaries with any other key type are
encoded as arrays of two-element [key, value] arrays; this preserves key types
without culture-sensitive or lossy string conversion.
Enums are strings by default and honor PolyType enum aliases. Set
SerializeEnumValuesByName to false to write their underlying numeric values.
Dates use the ISO 8601 representation produced by Utf8JsonWriter, and
TimeSpan values use the invariant constant (c) format.
Int128, UInt128, and BigInteger values are written as JSON numbers. A
consumer whose number model is limited to IEEE 754 may lose precision.
ShapeShiftBinary is written as base64 JSON text. Because JSON has no binary
token, untyped JSON deserialization reads such text as ShapeShiftString;
applications that require round-trip type identity should place binary data in
a strongly typed contract.
Non-finite floating-point values are rejected by default because JSON has no
standard representation for them. Set AllowNamedFloatingPointValues to
true to write and accept the strings "NaN", "Infinity", and
"-Infinity".
JSON Schema
JsonSerializer.GetJsonSchema renders the type's contract as a JSON Schema
2020-12 document, and JsonSchema.Create projects a contract obtained from any
serializer (including MsgPackSerializer, with MessagePack annotations). See
Schema and contract inspection.
Reader security
ShapeShift rejects duplicate object properties and missing required constructor
arguments by default. The shared StartingContext controls maximum depth and
collection length. Comments and trailing commas remain disabled unless
explicitly enabled.
Targeted and streaming deserialization
See Targeted and streaming deserialization
for the format-neutral ShapeShiftPath, TrySeek, fragment deserialization,
and sequence/document reader APIs, all of which JsonDecoder supports.
Prefer GetPath with a typed expression --
serializer.GetPath((Person p) => p.Address.City) -- so the JSON property
names, including any that PropertyNamingPolicy rewrote, are produced for you.
Build a ShapeShiftPath by hand for payload-driven locations instead.
Utf8JsonReader only supports a single top-level JSON value per instance.
JsonDecoder transparently constructs a fresh reader over the unconsumed
input whenever a ShapeShiftDocumentReader<T> (or any other caller) reads
past one top-level value into genuine further content, so a single
JsonDecoder can walk an entire newline-delimited JSON (NDJSON) stream, or
any other buffer of concatenated top-level values, without the caller
reconstructing anything itself.
Async I/O without sync-over-async
JsonSerializer exposes SerializeAsync/DeserializeAsync overloads for
Stream, PipeWriter, and PipeReader, plus DeserializeAllAsync for a
sequence of concatenated top-level values (such as NDJSON), all without ever
calling .Wait(), .Result, or GetAwaiter().GetResult() on synchronous
work, and without a fake-async TextReader.ReadToEnd equivalent:
var serializer = new JsonSerializer();
var person = new Person("Ada");
// SerializeAsync/DeserializeAsync incrementally fill/drain a bounded buffer around the
// existing synchronous conversion. They never buffer an entire document up front, never
// block a thread waiting on I/O, and honor cancellation throughout.
using var stream = new MemoryStream();
await serializer.SerializeAsync(stream, person);
stream.Position = 0;
Person? copy = await serializer.DeserializeAsync<Person>(stream);
// The same APIs work directly against a PipeWriter/PipeReader, e.g. the ends of a
// System.IO.Pipelines.Pipe, or a transport's own pipe.
var pipe = new Pipe();
await serializer.SerializeAsync(pipe.Writer, person);
await pipe.Writer.CompleteAsync();
Person? fromPipe = await serializer.DeserializeAsync<Person>(pipe.Reader);
// DeserializeAllAsync enumerates a stream of concatenated top-level values -- such as
// newline-delimited JSON (NDJSON) -- one at a time, buffering only as much of the
// underlying pipe as each individual value requires.
string ndjson = "{\"Name\":\"Ada\"}\n{\"Name\":\"Grace\"}\n{\"Name\":\"Katherine\"}\n";
using var ndjsonStream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
PipeReader ndjsonReader = PipeReader.Create(ndjsonStream);
List<Person?> people = [];
await foreach (Person? p in serializer.DeserializeAllAsync<Person>(ndjsonReader))
{
people.Add(p);
}
Serialization writes the value once (via the existing synchronous
Serialize(IBufferWriter<byte>, ...) conversion) and then flushes the
PipeWriter/Stream asynchronously. Deserialization instead reads a
PipeReader/Stream incrementally: a JsonValueBoundaryScanner drives
Utf8JsonReader's own incremental-parsing support (JsonReaderState
resumption and TrySkip()) to recognize, without fully decoding, when one
complete top-level value has been buffered, releasing insignificant
whitespace between values back to the pipe as soon as it is confirmed safe to
discard. Only then does the existing synchronous JsonDecoder run once, over
that value's bytes. maxBufferedSize bounds how large a single value's
buffered span may grow while still unresolved, guarding against a value that
never completes (for example, a truncated payload or a hostile, unbounded
input) without capping the size of the whitespace that may separate
well-formed values in a long-running NDJSON-style sequence. All overloads
accept a CancellationToken and use ConfigureAwait(false) throughout.