Customizing the core
Everything on this page is declared by the format-neutral ShapeShift package,
so it applies identically to ShapeShift.Json, ShapeShift.MsgPack,
ShapeShift.Yaml, ShapeShift.Taml, and any third-party format package. The
samples use JsonSerializer only because its output is readable.
Nothing here uses reflection: converters are supplied as instances or built by
factories from source-generated PolyType shapes, so a customized serializer is
as trimming-safe and NativeAOT-safe as an unconfigured one. The one exception is
the explicitly annotated WithReflectionConverterTypes opt-in described under
Reflection-based activation.
Immutable configuration
A serializer is an immutable record. Configure it at construction, hold it in a
static field, and use it concurrently; there are no mutable process-wide
defaults to race with. Deriving a variation with a with expression produces a
new serializer and leaves the original — and its converter cache — alone.
// Derives a second configuration from a shared baseline without mutating the baseline.
public static (string Baseline, string Compact) ConfigureImmutably()
{
// A serializer is an immutable record, so one instance can be a static field of an
// application and used concurrently. Configure it once, at construction.
JsonSerializer baseline = new()
{
PropertyNamingPolicy = ShapeShiftNamingPolicy.CamelCase,
StartingContext = new() { MaxDepth = 16 },
};
// `with` derives a second configuration. `baseline` is unchanged and still usable,
// and each serializer keeps its own converter cache.
JsonSerializer compact = baseline with
{
SerializeDefaultValues = SerializeDefaultValuesPolicy.Required,
};
Reservation reservation = new("Ada");
return (baseline.Serialize(reservation), compact.Serialize(reservation));
}
Each distinct configuration builds its own converter cache the first time it is used, so prefer a small number of long-lived serializers over one per operation.
Naming
PropertyNamingPolicy renames every property that does not name itself.
CamelCase, PascalCase, KebabLowerCase, KebabUpperCase, SnakeLowerCase,
and SnakeUpperCase are built in, and a custom policy is one ConvertName
override away.
// Renames properties and enum values on the wire without touching the CLR model.
public static string ApplyNamingPolicy()
{
JsonSerializer serializer = new()
{
// Applies to every property that does not declare its own [PropertyShape(Name = "...")].
PropertyNamingPolicy = ShapeShiftNamingPolicy.SnakeLowerCase,
// Enum values are written by name by default; set this to false to write ordinals.
SerializeEnumValuesByName = true,
};
return serializer.Serialize(new Reservation("Ada", RoomKind.Suite, Nights: 3));
}
Individual members opt out by naming themselves, which is also how a member is excluded from the contract entirely:
public class Registration
{
[PropertyShape(Ignore = true)] // exclude this property from serialization
public string? ScratchPad { get; set; }
[PropertyShape] // include this non-public property in serialization
internal string? InternalNote { get; set; }
}
public class Guest
{
[PropertyShape(Name = "name")] // serialize this property as "name"
public string? GuestName { get; set; }
}
Enum values are written by name by default and honor their own aliases. Set
SerializeEnumValuesByName to false to write the underlying numbers instead.
public enum Floor
{
/// <summary>The first floor.</summary>
[EnumMemberShape(Name = "1st")] // serialize this enum value as "1st"
First,
/// <summary>The second floor.</summary>
[EnumMemberShape(Name = "2nd")] // serialize this enum value as "2nd"
Second,
}
The analyzers report a name that two members would share, either directly (SHIFT005) or after a naming policy is applied (SHIFT006).
Default-value omission
SerializeDefaultValues chooses which properties whose values equal their
declared defaults are written. Always (the default) writes everything;
Required keeps the values needed to reconstruct the object and drops the rest;
Never drops them all; ValueTypes and ReferenceTypes select by category.
// Omits properties whose values match their declared defaults, keeping the required ones.
public static string OmitDefaultValues()
{
JsonSerializer serializer = new()
{
// Never omits every defaulted property; Required keeps the ones needed to reconstruct
// the object. Both are safe for map-shaped formats such as JSON.
SerializeDefaultValues = SerializeDefaultValuesPolicy.Required,
};
// Room, Nights, Deposit, Notes, and Preferences all still hold their declared defaults,
// so only the constructor parameter that has no default is written.
return serializer.Serialize(new Reservation("Ada"));
}
Omission is a property of map-shaped output. A positional encoding cannot generally omit an interior value without a presence scheme, so format-specific positional converters may decline it; see MessagePack positional contracts.
Strictness
Deserialization is strict by default: a duplicate property is an error, a missing required constructor parameter or required member is an error, and a null assigned to a non-nullable member is an error. Each failure carries a path breadcrumb naming the offending value.
// Contrasts the strict default deserialization policy with an explicitly relaxed one.
public static (string Rejected, Reservation Accepted) RejectIncompletePayloads()
{
const string MissingRequiredValue = """{"Nights":2}""";
// The default policy rejects a payload that omits a required value or assigns null to a
// non-nullable member, and reports where the offending value belonged.
JsonSerializer strict = new();
string rejected;
try
{
strict.Deserialize<Reservation>(MissingRequiredValue);
throw new InvalidOperationException("The strict policy was expected to reject this payload.");
}
catch (ShapeShiftSerializationException ex)
{
rejected = ex.Message;
}
// Relaxing the policy is explicit and per-serializer; it leaves the declared default in place.
JsonSerializer lenient = strict with
{
DeserializeDefaultValues = DeserializeDefaultValuesPolicy.AllowMissingValuesForRequiredProperties,
};
return (rejected, lenient.Deserialize<Reservation>(MissingRequiredValue)!);
}
DeserializeDefaultValuesPolicy relaxes those rules deliberately and per
serializer:
| Value | Effect |
|---|---|
Default |
Rejects missing required values and nulls for non-nullable members. |
AllowNullValuesForNonNullableProperties |
Accepts an explicit null for a non-nullable member. |
AllowMissingValuesForRequiredProperties |
Also accepts a payload that omits a required value, leaving the declared default in place. |
Immutable types are supported without relaxing anything: a constructor parameter is matched to the property it initializes, including when that property renames itself.
public class ImmutableGuest
{
// The parameter is matched to the property it initializes, which is written as "person_name".
public ImmutableGuest(string? name) => this.Name = name;
[PropertyShape(Name = "person_name")]
public string? Name { get; }
}
Security limits
Untrusted input is bounded by the context every operation starts from. The limits below are enforced by the shared converters and by every conforming format package, and custom converters are expected to honor them too.
// Bounds the work an untrusted payload can ask for.
public static string BoundUntrustedInput()
{
JsonSerializer serializer = new()
{
StartingContext = new()
{
MaxDepth = 8,
MaxCollectionLength = 4,
MaxStringLength = 1024,
MaxBinaryLength = 4096,
},
};
try
{
serializer.Deserialize<Reservation>("""{"GuestName":"Ada","Notes":["a","b","c","d","e"]}""");
throw new InvalidOperationException("The configured limit was expected to reject this payload.");
}
catch (ShapeShiftSerializationException ex)
{
return ex.Message;
}
}
| Limit | Default | Bounds |
|---|---|---|
MaxDepth |
64 | Nesting of the object graph. |
MaxCollectionLength |
1,000,000 | Elements in one collection. |
MaxStringLength |
16,777,216 | Characters in one string. |
MaxBinaryLength |
67,108,864 | Bytes in one binary value. |
Hostile dictionary keys are a separate concern: choose a comparer for the member rather than relying on the type's default equality. Specify it with an attribute so ShapeShift uses it while deserializing, and in the initializer so code that constructs the object uses it too.
public class Directory
{
// The attribute governs the dictionary ShapeShift creates while deserializing; the property
// initializer governs the one user code creates. Specify both so they agree.
[UseComparer(typeof(StringComparer), nameof(StringComparer.OrdinalIgnoreCase))]
public Dictionary<string, string> EntriesByName { get; } = new(StringComparer.OrdinalIgnoreCase);
}
Collision-resistant hashing for structural comparers is a distinct opt-in; see Structural equality and hashing.
Custom converters
Register a converter instance when a type has one representation the whole
application agrees on. This is the most direct customization: no activation and
nothing for trimming to preserve. Overriding GetContract keeps
schema generation honest about what the converter actually writes.
// Writes a Money as "25.5 USD" instead of as an object with two properties.
// A converter instance is the most direct customization: no reflection and no activation,
// so there is nothing for trimming or NativeAOT to preserve.
public sealed class MoneyConverter : ShapeShiftConverter<Money, JsonEncoder, JsonDecoder>
{
// The context state key whose value supplies the currency for a payload that omits one.
// An object reference is used rather than a string so the key cannot collide with another
// component's key.
public static readonly object DefaultCurrencyKey = new();
public override Money Read(ref JsonDecoder decoder, SerializationContext<JsonEncoder, JsonDecoder> context)
{
string text = decoder.ReadString();
int separator = text.LastIndexOf(' ');
if (separator < 0)
{
return context[DefaultCurrencyKey] is string currency
? new Money(ParseAmount(text), currency)
: throw new ShapeShiftSerializationException($"'{text}' has no currency and no default currency was supplied.");
}
return new Money(ParseAmount(text.AsSpan(0, separator)), text[(separator + 1)..]);
}
public override void Write(ref JsonEncoder encoder, in Money value, SerializationContext<JsonEncoder, JsonDecoder> context)
=> encoder.WriteValue(FormattableString.Invariant($"{value.Amount} {value.Currency}"));
// Describes what this converter really writes, so schema consumers see a string rather than
// the "undocumented" contract an unannotated converter produces.
public override DataContract GetContract(ContractContext<JsonEncoder, JsonDecoder> context)
=> new PrimitiveContract(typeof(Money), PrimitiveDataType.String);
private static decimal ParseAmount(ReadOnlySpan<char> text)
=> decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal amount)
? amount
: throw new ShapeShiftSerializationException($"'{text}' is not a valid amount.");
}
Converters are appended to the collection the format already installed, not substituted for it, so format-provided converters survive:
// Registers a converter instance and two converter factories. None of them uses reflection,
// so the result is trimming-safe and NativeAOT-safe.
public static JsonSerializer CreateConfiguredSerializer()
{
JsonSerializer serializer = new();
// Append to the converters the format already installed rather than replacing them,
// so JsonElement, JsonNode, and binary support survive.
return serializer with
{
Converters = [.. serializer.Converters, new MoneyConverter()],
ConverterFactories =
[
new EmbeddedDocumentConverterFactory(typeof(GuestPreferences)),
new NullTolerantListConverterFactory(),
],
};
}
A converter may also be attached to a type, property, or parameter with
ShapeShiftConverterAttribute, which PolyType resolves as an associated type
rather than by reflection. The analyzers verify that the attributed type really
is a converter (SHIFT001), that it can be constructed
(SHIFT002), and that it converts the type it is applied
to (SHIFT003).
Converter factories
A factory answers for types it cannot enumerate ahead of time. It is consulted
after Converters and returns null for anything it does not handle.
When the factory knows exactly which type it serves, no generic type parameter is needed:
// A special purpose factory knows exactly what it supports, so no generic type parameter is needed.
public sealed class MoneyConverterFactory : IShapeShiftConverterFactory<JsonEncoder, JsonDecoder>
{
public ShapeShiftConverter<JsonEncoder, JsonDecoder>? CreateConverter(Type type, ITypeShape? shape, in ConverterContext<JsonEncoder, JsonDecoder> context)
=> type == typeof(Money) ? new MoneyConverter() : null;
}
When the converter needs the converted type as a generic type parameter,
implement ITypeShapeFunc on the same class and let the shape call back into
it. ITypeShape<T>.Invoke supplies T without reflection, which is what keeps
this pattern NativeAOT-safe:
// Writes the listed types as a JSON *string* holding their own JSON document, the shape some HTTP
// APIs require of an embedded payload.
public sealed class EmbeddedDocumentConverterFactory : IShapeShiftConverterFactory<JsonEncoder, JsonDecoder>, ITypeShapeFunc
{
private readonly Type[] embeddedTypes;
public EmbeddedDocumentConverterFactory(params Type[] embeddedTypes) => this.embeddedTypes = embeddedTypes;
// The type check needs no generic type parameter, so it happens here; the converter does need
// one, so this method hands the shape back to the generic method below.
public ShapeShiftConverter<JsonEncoder, JsonDecoder>? CreateConverter(Type type, ITypeShape? shape, in ConverterContext<JsonEncoder, JsonDecoder> context)
=> shape is not null && Array.IndexOf(this.embeddedTypes, type) >= 0
? (ShapeShiftConverter<JsonEncoder, JsonDecoder>?)shape.Invoke(this)
: null;
// The type check is already done, so just create the converter. ITypeShape<T>.Invoke supplies
// the generic type parameter without reflection, which keeps this factory NativeAOT-safe.
object? ITypeShapeFunc.Invoke<T>(ITypeShape<T> typeShape, object? state)
=> new EmbeddedDocumentConverter<T>(typeShape);
}
public sealed class EmbeddedDocumentConverter<T> : ShapeShiftConverter<T, JsonEncoder, JsonDecoder>
{
// The embedded document is written by an ordinary serializer of its own, which is how it gets
// its own policies (and why it never recurses back into the outer serializer's factories).
private static readonly JsonSerializer Embedded = new();
private readonly ITypeShape<T> shape;
public EmbeddedDocumentConverter(ITypeShape<T> shape) => this.shape = shape;
public override T? Read(ref JsonDecoder decoder, SerializationContext<JsonEncoder, JsonDecoder> context)
{
if (decoder.TryReadNull())
{
return default;
}
context.DepthStep();
byte[] utf8 = Encoding.UTF8.GetBytes(decoder.ReadString());
JsonDecoder embedded = new(utf8);
return Embedded.Deserialize(ref embedded, this.shape, context.CancellationToken);
}
public override void Write(ref JsonEncoder encoder, in T? value, SerializationContext<JsonEncoder, JsonDecoder> context)
{
context.DepthStep();
ArrayBufferWriter<byte> buffer = new();
using (System.Text.Json.Utf8JsonWriter writer = new(buffer))
{
JsonEncoder embedded = new(writer);
Embedded.Serialize(ref embedded, value, this.shape, context.CancellationToken);
}
encoder.WriteValue(Encoding.UTF8.GetString(buffer.WrittenSpan));
}
}
When the converter needs generic type parameters for parts of the type — the
element type of a collection, for instance — use a TypeShapeVisitor, and ask
the ConverterContext for the converters of those parts:
// Reads a null JSON array as an empty list, which lets a service accept peers that write null for
// an absent collection without making every model property nullable.
public sealed class NullTolerantListConverterFactory : IShapeShiftConverterFactory<JsonEncoder, JsonDecoder>
{
// The converter needs the *element* type as a generic type parameter, which a TypeShapeVisitor
// supplies. Perform the type check, then defer to the visitor.
public ShapeShiftConverter<JsonEncoder, JsonDecoder>? CreateConverter(Type type, ITypeShape? shape, in ConverterContext<JsonEncoder, JsonDecoder> context)
=> shape is IEnumerableTypeShape && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)
? (ShapeShiftConverter<JsonEncoder, JsonDecoder>?)shape.Accept(Visitor.Instance, context)
: null;
private sealed class Visitor : TypeShapeVisitor
{
internal static readonly Visitor Instance = new();
public override object? VisitEnumerable<TEnumerable, TElement>(IEnumerableTypeShape<TEnumerable, TElement> enumerableShape, object? state = null)
{
var context = (ConverterContext<JsonEncoder, JsonDecoder>)state!;
return new NullTolerantListConverter<TElement>(context.GetConverter(enumerableShape.ElementType));
}
}
}
public sealed class NullTolerantListConverter<TElement> : ShapeShiftConverter<List<TElement>, JsonEncoder, JsonDecoder>
{
private readonly ShapeShiftConverter<TElement, JsonEncoder, JsonDecoder> elementConverter;
public NullTolerantListConverter(ShapeShiftConverter<TElement, JsonEncoder, JsonDecoder> elementConverter)
=> this.elementConverter = elementConverter;
public override List<TElement> Read(ref JsonDecoder decoder, SerializationContext<JsonEncoder, JsonDecoder> context)
{
if (decoder.TryReadNull())
{
return [];
}
context.DepthStep();
List<TElement> elements = decoder.ReadStartVector() is int count ? new(count) : [];
while (decoder.NextTokenType != TokenType.EndVector)
{
// A custom converter is responsible for honoring the context's security limits.
if (elements.Count == context.MaxCollectionLength)
{
throw new ShapeShiftSerializationException($"Collection length exceeds the configured maximum of {context.MaxCollectionLength}.");
}
elements.Add(this.elementConverter.Read(ref decoder, context)!);
}
decoder.ReadEndVector();
return elements;
}
public override void Write(ref JsonEncoder encoder, in List<TElement>? value, SerializationContext<JsonEncoder, JsonDecoder> context)
{
context.DepthStep();
List<TElement> elements = value ?? [];
encoder.WriteStartVector(elements.Count);
foreach (TElement element in elements)
{
this.elementConverter.Write(ref encoder, element, context);
}
encoder.WriteEndVector();
}
}
A converter obtained from ConverterContext or SerializationContext is the
converter the serializer would otherwise have used, so delegating to it composes
with the rest of the configuration. Call DepthStep before converting nested
values, and honor MaxCollectionLength and the other limits, so a custom
converter is as safe against hostile input as a built-in one.
Converter state
StartingContext supplies the ambient state and limits each operation begins
with. Because the context is a struct, change a local copy and reassign it:
// The context is a struct, so change a local copy and reassign it to the serializer.
SerializationContext<JsonEncoder, JsonDecoder> context = serializer.StartingContext;
context[MoneyConverter.DefaultCurrencyKey] = "USD";
serializer = serializer with
{
StartingContext = context,
};
Use an object reference as the key, exposed by whichever component reads it, so that two unrelated components cannot collide on the same string.
// Reads a payload whose money value omits its currency, which the converter takes from the
// ambient state the caller placed in the starting context.
public static Reservation ApplyConverterState()
{
JsonSerializer serializer = CreateConfiguredSerializer();
SerializationContext<JsonEncoder, JsonDecoder> context = serializer.StartingContext;
context[MoneyConverter.DefaultCurrencyKey] = "USD";
serializer = serializer with { StartingContext = context };
return serializer.Deserialize<Reservation>("""{"GuestName":"Ada","Deposit":"25.00"}""")!;
}
Reflection-based activation
WithReflectionConverterTypes accepts converter Type objects and activates
them at runtime. It is annotated with RequiresDynamicCode and
RequiresUnreferencedCode and reported by
SHIFT007, because a constructor reached only through a
Type can be trimmed away. An application that never calls it is unaffected:
converter instances, factories, and generated shapes remain the default path.
Sample
The complete, executable sample used above is
CoreCustomization.cs,
and it is exercised by test/Samples.Tests.