Shape providers

This document provides a walkthrough of the built-in type shape providers. These are typically consumed by end users looking to use their types with libraries built on top of the PolyType core abstractions.

Source Generator

We can use the built-in source generator to auto-generate shape metadata for a user-defined type like so:

using PolyType;

[GenerateShape]
partial record Person(string name, int age, List<Person> children);

This augments Person with an explicit implementation of IShapeable<Person>, which can be used an entry point by libraries targeting PolyType:

MyRandomGenerator.Generate<Person>(); // Compiles

public static class MyRandomGenerator
{
    public static T Generate<T>(int seed = 0) where T : IShapeable<T>;
}

The source generator also supports shape generation for third-party types using witness types:

[GenerateShape<Person[]>]
[GenerateShape<List<int>>]
public partial class Witness; // : IShapeable<Person[]>, IShapeable<List<int>>

which can be applied against supported libraries like so:

MyRandomGenerator.Generate<Person[], Witness>() // Compiles
MyRandomGenerator.Generate<List<int>, Witness>() // Compiles

public static class MyRandomGenerator
{
    public static T Generate<T, TWitness>(int seed = 0) where TWitness : IShapeable<T>;
}

Reflection Provider

PolyType includes a reflection-based provider that resolves shape metadata at run time:

using PolyType.ReflectionProvider;

ITypeShapeProvider provider = ReflectionTypeShapeProvider.Default;
var shape = (ITypeShape<Person>)provider.GetShape(typeof(Person));

Which can be consumed by supported libraries as follows:

MyRandomGenerator.Generate<Person>(ReflectionTypeShapeProvider.Default);
MyRandomGenerator.Generate<Person[][]>(ReflectionTypeShapeProvider.Default);
MyRandomGenerator.Generate<List<int>>(ReflectionTypeShapeProvider.Default);

public static class MyRandomGenerator
{
    public static T Generate<T>(ITypeShapeProvider provider);
}

By default, the reflection provider uses dynamic methods (Reflection.Emit) to speed up reflection, however this might not be desirable when running in certain platforms (e.g. blazor-wasm). It can be turned off using the relevant constructor parameter:

ITypeShapeProvider provider = new ReflectionTypeShapeProvider(useReflectionEmit: false);

Shape attributes

PolyType exposes a number of attributes that tweak aspects of the generated shape. These attributes are recognized both by the source generator and the reflection provider.

TypeShapeAttribute

The TypeShape attribute can be applied on type declarations to customize their generated shapes. It is independent of the GenerateShape attribute since it doesn't trigger the source generator and is recognized by the reflection-based provider.

The Kind property can be used to override the default shape kind for a particular type:

[TypeShape(Kind = TypeShapeKind.Object)]
class MyList : List<int>
{
    public int Value { get; set; }
}

The above will instruct the providers to generate an object shape as opposed to an enumerable shape that is the default. It can also be used to completely disable any nested member resolution for a given type:

[TypeShape(Kind = TypeShapeKind.None)]
record MyPoco(int Value);

Surrogate types

The TypeShape attribute can also be used to specify marshallers to surrogate types:

[TypeShape(Marshaller = typeof(EnvelopeMarshaller))]
record Envelope(string Value);

class EnvelopeMarshaller : IMarshaller<Envelope, string>
{
    public string? ToSurrogate(Envelope? envelope) => envelope?.Value;
    public Envelope? FromSurrogate(string? surrogateString) => surrogateString is null ? null : new(surrogateString);
}

The above configures Envelope to admit a string-based shape using the specified surrogate representation. In other words, it assumes a string schema instead of that of an object. Surrogates are used as a more versatile alternative compared to format-specific converters.

In the following example, we marshal the internal state of an object to a surrogate struct:

[TypeShape(Marshaller = typeof(Marshaller))]
public class PocoWithInternalState(int value1, string value2)
{
    private readonly int _value1 = value1;
    private readonly string _value2 = value2;

    public record struct Surrogate(int Value1, string Value2);

    public sealed class Marshaller : IMarshaller<PocoWithInternalState, Surrogate>
    {
        public Surrogate ToSurrogate(PocoWithInternalState? poco) => poco is null ? default : new(poco._value1, poco._value2);
        public PocoWithInternalState FromSurrogate(Surrogate surrogate) => new(surrogate._value1, surrogate._value2 ?? "");
    }
}

It's possible to define marshallers for generic types, provided that the type parameters of the marshaller match the type parameters of declaring type:

[TypeShape(Marshaller = typeof(Marshaller<>))]
public record MyPoco<T>(T Value);

public class Marshaller<T> : IMarshaller<MyPoco<T>, T>
{
    public T? ToSurrogate(MyPoco<T>? value) => value is null ? default : value.Value;
    public MyPoco<T>? FromSurrogate(T? value) => value is null ? null : new(value);
}

[GenerateShape<MyPoco<string>>]
public partial class Witness;

The above will configure MyPoco<string> with a marshaller of type Marshaller<string>. Nested generic marshallers are also supported:

[TypeShape(Marshaller = typeof(MyPoco<>.Marshaller))]
public record MyPoco<T>(T Value)
{
    public class Marshaller : IMarshaller<MyPoco<T>, T>
    {
        /* Implementation goes here */
    }
}

Polymorphic types

The DerivedTypeShape attribute can be used to declare polymorphic type hierarchies for classes and interfaces:

[DerivedTypeShape(typeof(Horse))]
[DerivedTypeShape(typeof(Dog))]
[DerivedTypeShape(typeof(Cat))]
interface IAnimal;

class Dog : IAnimal;
class Cat : IAnimal;
class Horse : IAnimal;

The above incorporates the shapes for Cat, Dog and Horse as polymorphic cases in the shape of IAnimal. Serializing an instance of type Dog as IAnimal using the example JSON serializer will produce the following payload:

{ "$type": "Dog" }

Each derived type declaration is given a unique string identifier (the name) and a unique integer identifier (the tag). The former is used as a type discriminator in the case of text-based formats like XML or JSON and the latter is used as a discriminator in compact binary formats like CBOR or MessagePack. Either name or tag can be specified explicitly for each derived type declaration:

[DerivedTypeShape(typeof(Leaf), Name = "leaf", Tag = 5)]
[DerivedTypeShape(typeof(Leaf), Name = "node", Tag = 6)]
abstract record BinTree;
abstract record Leaf : BinTree;
abstract record Node(int label, int left, int right) : BinTree

If left unset, the name of a derived type defaults to its type name (i.e. nameof(TDerived)) and the tag corresponds to the attribute declaration order. It should be noted that mono reflection does not preserve attribute declaration ordering, so it is recommended that applications targeting mono should either use the source generator or explicitly set the tags for all model types.

For the case of unregistered derived types, PolyType applies a "nearest known ancestor" resolution algorithm. Given the type hierarchy

[DerivedTypeShape(typeof(Horse))]
[DerivedTypeShape(typeof(Cow))]
class Animal;
class Horse : Animal;
class Cow : Animal;

class Pony : Horse;
class Chicken : Animal;

instances of type Pony will resolve as Horse and instances of type Chicken will resolve as the Animal base. Note that this can result in undefined behavior in the case of diamonds in interface hierarchies:

[DerivedTypeShape(typeof(IDerived1))]
[DerivedTypeShape(typeof(IDerived2))]
interface IBase;
interface IDerived1 : IBase;
interface IDerived2 : IBase;

class Impl : IDerived1, IDerived2;

Instances of type Impl could resolve as either IDerived1 or IDerived2, depending on the particular runtime and shape provider implementation. This ambiguity can be resolved by explicitly adding a DerivedTypeShape declaration for Impl or any intermediate interface type implementing both IDerived1 and IDerived2.

PropertyShapeAttribute

Configures aspects of a generated property shape, for example:

class UserData
{
    [PropertyShape(Name = "id", Order = 0)]
    public required string Id { get; init; }

    [PropertyShape(Name = "name", Order = 1)]
    public string? Name { get; init; }

    [PropertyShape(Ignore = true)]
    public string? UserSecret { get; init; }
}

Compare with System.Runtime.Serialization.DataMemberAttribute and Newtonsoft.Json.JsonPropertyAttribute.

ConstructorShapeAttribute

Can be used to pick a specific constructor for a given type, if there is ambiguity:

class PocoWithConstructors
{
    public PocoWithConstructors();
    [ConstructorShape] // <--- Only use this constructor in PolyType apps
    public PocoWithConstructors(int x1, int x2);
}

Compare with System.Text.Json.Serialization.JsonConstructorAttribute.

ParameterShapeAttribute

Configures aspects of a constructor parameter shape:

class PocoWithConstructors
{
    public PocoWithConstructors([ParameterShape(Name = "name")] string x1, [ParameterShape(Name = "age")] int x2);
}