Table of Contents

Features

NativeAOT-ready contracts

ShapeShift uses PolyType source-generated type shapes for NativeAOT-ready object mapping. Reflection-based converter type activation is available only through the explicitly annotated WithReflectionConverterTypes opt-in. Converter instances, factories, and generated shapes remain reflection-free.

Shared format-neutral behavior

Every ShapeShift format shares support for:

  • Objects with mutable or parameterized construction.
  • Mutable and immutable collections.
  • Rectangular arrays of any rank.
  • String-keyed maps and non-string-keyed dictionary entries.
  • Nullable and other optional values.
  • Enum names and numeric values.
  • PolyType surrogates and attributed unions.
  • Custom converters and converter factories.
  • Naming policies, callbacks, reference preservation, and string interning.
  • Default-value omission.
  • Strict duplicate, required-member, and non-nullable-member validation.
  • Configurable depth, collection, string, and binary length limits.

Objects are written as maps of named properties in every format, which is the version-tolerant choice. A format whose wire model rewards compactness may also offer an explicit positional mode with stricter versioning rules; see MessagePack positional contracts.

All of it is configured the same way in every format: immutably, on the serializer instance. See Customizing the core for the naming, default-value, strictness, and security policies, and for custom converters and converter factories.

Reference preservation

PreserveReferences makes an object graph that shares objects stay a graph: an object is written once and referred back to afterwards, and the reader reconstructs the sharing. RejectCycles preserves identity while refusing cycles; AllowCycles also reconstructs graphs that refer back to themselves.

Each format chooses how a reference is represented, because there is no format-neutral answer: see MessagePack reference preservation for the reserved extension MessagePack uses, its size, and the errors a mismatched reader reports. Because references are a runtime protocol rather than a static shape, GetContract is not available while preservation is enabled.

Targeted and streaming deserialization

Every ShapeShift decoder supports skipping and seeking without deserializing intervening content, and reading a sequence of top-level values (or the elements of a nested vector) one at a time.

ShapeShiftPath identifies a location within a document as a sequence of property names and vector indices, independent of any particular format. You rarely have to spell one out: GetPath translates an ordinary C# expression into the path that locates the same value, using the serializer's own contract for the root type — so the caller never needs to know the serialized names:

var serializer = new JsonSerializer { PropertyNamingPolicy = ShapeShiftNamingPolicy.CamelCase };

string json = """
    {
        "name": "Ada",
        "address": { "city": "London", "zip": "E1" },
        "tags": ["mathematician", "programmer"]
    }
    """;

// Describe the location with an ordinary C# expression. The serializer translates it
// through its own contract for Person, so the naming policy above (and any
// [PropertyShape(Name = "...")] alias) is applied for you.
ShapeShiftPath cityPath = serializer.GetPath((Person p) => p.Address.City);

// Deserialize just that one nested, strongly typed value, without allocating
// or converting the rest of the document.
bool found = serializer.TryDeserializeFragment<string, Witness>(json, cityPath, out string? city);

// Constant collection indexes work too, and so do whole sub-objects.
string? tag = serializer.DeserializeFragment<string, Witness>(json, serializer.GetPath((Person p) => p.Tags[1]));
Address? address = serializer.DeserializeFragment<Address>(json, serializer.GetPath((Person p) => p.Address));

Because the translation goes through the contract, PropertyNamingPolicy and any [PropertyShape(Name = "...")] alias are applied for you, and a positionally encoded object (such as a MessagePack array contract) contributes indices instead of names. The expression is only inspected — never compiled — so this stays trimming-safe and NativeAOT-safe.

These expression forms are supported:

  • The parameter itself (x => x), which is ShapeShiftPath.Root.
  • Member access, to any depth (x => x.Address.City), including through the null-forgiving operator.
  • Constant, non-negative indexes into arrays and lists (x => x.Tags[1]).
  • Constant string keys into string-keyed dictionaries (x => x.Attributes["hue"]).
  • Nullable<T>.Value, and boxing or widening reference conversions, all of which are stepped over.

Anything else — computed indexes, method calls, narrowing or user-defined conversions, members the shape ignores, extension-data members, dictionaries whose keys are not strings, and values produced by a custom converter that does not describe its representation — is rejected with an ArgumentException or NotSupportedException that quotes the failing step rather than guessing.

Raw paths remain first class, and are the right tool whenever the location is payload-driven rather than type-driven: an index chosen at runtime, or a property that no .NET type declares:

// A raw path remains the right tool when the location is payload-driven rather than
// type-driven: an index chosen at runtime, or a property no .NET type declares.
int which = json.Contains("mathematician", StringComparison.Ordinal) ? 0 : 1;
string? someTag = serializer.DeserializeFragment<string, Witness>(json, new ShapeShiftPath("tags", which));

Targeted deserialization keeps taking a ShapeShiftPath rather than gaining an expression overload of its own on every input type. A fragment API is already generic over the fragment's type and its shape provider; adding a root type and root shape provider to each one would multiply the overloads on every serializer without saying anything that GetPath plus the existing API does not already say. Computing a path once with GetPath and reusing it is also cheaper, since a path is immutable and independent of the payload.

TrySeek (a ref-receiver extension member on IDecoder) advances a decoder to the value at a path, skipping everything else along the way without allocating or converting it, and returns false (leaving the decoder unusable for further reads of the current value) if the path does not exist in the document. TryDeserializeFragment/DeserializeFragment combine TrySeek with an ordinary typed deserialize of whatever is found there.

ShapeShiftSequenceReader<T> and ShapeShiftDocumentReader<T> enumerate multiple values sharing one decoder without loading them all into memory at once:

  • A sequence reader enumerates the elements of a vector — the root of a document, or one reached first by TrySeek — the same way a JSON array or MessagePack array's elements would otherwise all be deserialized together into a single collection.
  • A document reader enumerates whole top-level values, one after another, until the decoder reaches the end of its input. This supports newline-delimited JSON (NDJSON) and any other stream of concatenated top-level values.
var serializer = new JsonSerializer();

// ShapeShiftDocumentReader<T> enumerates whole top-level values one at a time,
// reading each into a strongly typed value without buffering the others.
// This works for newline-delimited JSON (NDJSON) and other streams of
// concatenated top-level values.
string ndjson = """
    {"Name":"Ada"}
    {"Name":"Grace"}
    {"Name":"Katherine"}
    """;

JsonDecoder decoder = new(Encoding.UTF8.GetBytes(ndjson));
using ShapeShiftDocumentReader<Person, JsonEncoder, JsonDecoder> documentReader = serializer.CreateDocumentReader<Person>();

List<Person?> people = [];
while (documentReader.MoveNext(ref decoder))
{
    people.Add(documentReader.Current);
}

// ShapeShiftSequenceReader<T> is similar, but for elements of a JSON array (or
// MessagePack vector) rather than concatenated top-level values. It can be
// combined with TrySeek to enumerate a vector nested anywhere in a larger document.
string json = """{"Team":"Analytical Engine","Members":["Ada","Grace","Katherine"]}""";
JsonDecoder nestedDecoder = new(Encoding.UTF8.GetBytes(json));
bool foundMembers = nestedDecoder.TrySeek(new ShapeShiftPath("Members"));
using ShapeShiftSequenceReader<string, JsonEncoder, JsonDecoder> sequenceReader = serializer.CreateSequenceReader<string, Witness>();

List<string?> members = [];
while (foundMembers && sequenceReader.MoveNext(ref nestedDecoder))
{
    members.Add(sequenceReader.Current);
}

Both reader types are plain (non-ref) structs that do not themselves store the decoder, so a foreach-like loop passes the same decoder by ref to each call to MoveNext. This keeps them usable across await boundaries even though the decoders they read from are typically ref struct types that cannot themselves cross an await. Dispose a reader (or use a using statement) when finished with it to release any pooled resources it may hold.

A decoder that reads a segmented ReadOnlySequence<byte> should walk it in place rather than consolidating it, so that a targeted read of a small fragment costs a small fragment's worth of work no matter how large the surrounding document is or how it happens to be chopped into segments. ShapeShift.MsgPack does exactly that; see Segmented buffers and no-copy reads.

See JSON and MessagePack for format-specific notes.

Dynamic values

ShapeShiftValue is a NativeAOT-safe, format-neutral value tree. Its concrete types represent null, Boolean, signed and unsigned integers, arbitrary-precision integers, floating-point and decimal numbers, strings, binary data, arrays, and string-keyed maps.

Dynamic values do not load CLR types from payload metadata. This makes them suitable for inspecting untyped input without introducing typeless deserialization risks.

Unknown-property retention

Apply ShapeShiftExtensionDataAttribute to one Dictionary<string, ShapeShiftValue> member to capture properties that are not declared by the generated contract and write them back as peer properties:

var serializer = new JsonSerializer();
const string Payload = """{"Name":"Ada","future":{"enabled":true}}""";

ExtensiblePerson? person = serializer.Deserialize<ExtensiblePerson>(Payload);
string forwardedPayload = serializer.Serialize(person);

The extension-data member is excluded from the ordinary object contract, so its dictionary is flattened rather than nested under the CLR member name. Extension keys that collide with declared wire property names are rejected. A type may declare only one extension-data member. The member must have a getter; when it returns null, it must also have a setter so ShapeShift can assign a dictionary.

Extension-data deserialization currently requires a parameterless constructor. This avoids retaining untrusted data in constructor argument state and keeps construction deterministic. Maps are string-keyed because ShapeShift object contracts expose property names as strings.

Formats may not be able to preserve every distinction. For example, JSON has no native binary token, so a ShapeShiftBinary writes as base64 text but untyped JSON text is read as ShapeShiftString.

See JSON for JSON APIs and representation details.

Rectangular arrays use a two-element envelope containing a dimensions vector and a row-major flat values vector. For example, a 2 x 3 array is represented as [[2, 3], [v0, v1, v2, v3, v4, v5]]. This preserves zero-length dimensions that a naively nested representation would lose.

Schema and contract inspection

GetContract produces a format-neutral description of what a serializer would write, and ShapeShift.Json projects that description to JSON Schema 2020-12, optionally with MessagePack annotations. See Schema and contract inspection.

Structural equality and hashing

StructuralEqualityComparer.Create<T>() builds a deep IEqualityComparer<T> from the same source-generated shapes the serializer uses, covering objects, collections, dictionaries, unions, surrogates, ShapeShiftValue and cyclic graphs. Collision-resistant hashing is available as an opt-in policy. See Structural equality and hashing.

Diagnostics and analyzers

Every serialization failure carries a ShapeShiftPath breadcrumb that names the exact value that failed, and the ShapeShift package ships Roslyn analyzers that move common authoring mistakes forward to build time. Runtime behavior is correct without the analyzers. See Diagnostics and ShapeShift analyzers.

Third-party formats

The core is format-neutral, so a new format package supplies only an IEncoder, an IDecoder, and a serializer that binds them together; objects, collections, policies, limits, targeted reads, and schema generation come from the shared layer. ShapeShift.Conformance verifies that a new pair honors the contracts that layer relies on. See Authoring a format package.

Hosts and frameworks

Format packages take no framework dependencies. ASP.NET Core and SignalR applications use the asynchronous pipe APIs directly, which keeps them trimming-safe and NativeAOT-safe; see Host integration, which also records why dedicated MVC and SignalR packages are deferred and what would change that.