Core Abstractions

This document provides a walkthrough the core type abstractions found in PolyType. This includes ITypeShape, IPropertyShape and the visitor types for accessing them. These are typically consumed by library authors looking to build datatype-generic components. Unless otherwise stated, all APIs are found in the PolyType.Abstractions namespace.

The ITypeShape interface

The ITypeShape interface defines a reflection-like representation for a given .NET type. The type hierarchy that it creates encapsulates all information necessary to perform strongly typed traversal of its type graph.

To illustrate the idea, consider the following APIs modelling objects with properties:

namespace PolyType.Abstractions;

public partial interface IObjectTypeShape<TDeclaringType> : ITypeShape
{
    IReadOnlyList<IPropertyShape> Properties { get; }
}

public partial interface IPropertyShape<TDeclaringType, TPropertyType> : IPropertyShape
{
    IObjectTypeShape<TPropertyType> PropertyType { get; }
    Func<TDeclaringType, TPropertyType> GetGetter();
    bool HasGetter { get; }
}

This model is fairly similar to System.Type and System.Reflection.PropertyInfo, with the notable difference that both models are generic and the property shape is capable of producing a strongly typed getter delegate. It can be traversed using the following generic visitor type:

public abstract partial class TypeShapeVisitor
{
    object? VisitObject<TDeclaringType>(IObjectTypeShape<TDeclaringType> objectShape, object? state = null);
    object? VisitProperty<TDeclaringType, TPropertyType>(IPropertyShape<TDeclaringType, TPropertyType> typeShape, object? state = null);
}

public partial interface ITypeShape
{
    object? Accept(TypeShapeVisitor visitor, object? state = null);
}

public partial interface IPropertyShape
{
    object? Accept(TypeShapeVisitor visitor, object? state = null);
}

Here's a simple visitor used to construct delegates counting the number nodes in an object graph:

partial class CounterVisitor : TypeShapeVisitor
{
    public override object? VisitObject<T>(IObjectTypeShape<T> objectShape, object? _)
    {
        // Generate counter delegates for each individual property or field:
        Func<T, int>[] propertyCounters = objectShape.Properties
            .Where(prop => prop.HasGetter)
            .Select(prop => (Func<T, int>)prop.Accept(this)!)
            .ToArray();

        // Compose into a counter delegate for the current type.
        return new Func<T?, int>(value =>
        {
            if (value is null)
                return 0;

            int count = 1;
            foreach (Func<T, int> propertyCounter in propertyCounters)
                count += propertyCounter(value);

            return count;
        });
    }

    public override object? VisitProperty<TDeclaringType, TPropertyType>(IPropertyShape<TDeclaringType, TPropertyType> propertyShape, object? _)
    {
        Getter<TDeclaringType, TPropertyType> getter = propertyShape.GetGetter(); // extract the getter delegate
        var propertyTypeCounter = (Func<TPropertyType, int>)propertyShape.PropertyType.Accept(this)!; // extract the counter for the property shape
        return new Func<TDeclaringType, int>(obj => propertyTypeCounter(getter(ref obj))); // combine into a property-specific counter delegate
    }
}

Given an ITypeShape<T> instance we can now construct a counter delegate like so:

ITypeShape<MyPoco> shape = provider.GetShape<MyPoco>();
CounterVisitor visitor = new();
var pocoCounter = (Func<MyPoco, int>)shape.Accept(visitor)!;

pocoCounter(new MyPoco("x", "y")); // 3
pocoCounter(new MyPoco("x", null)); // 2
pocoCounter(new MyPoco(null, null)); // 1
pocoCounter(null); // 0

record MyPoco(string? x, string? y);

It should be noted that the visitor is only used when constructing, or folding the counter delegate but not when the delegate itself is being invoked. At the same time, traversing the type graph via the visitor requires casting of the intermediate delegates, however the traversal of the object graph via the resultant delegate is fully type-safe and doesn't require any casting.

Note

In technical terms, ITypeShape encodes a GADT representation over .NET types and TypeShapeVisitor encodes a pattern match over the GADT. This technique was originally described in this publication.

The casting requirement for visitors is a known restriction of this approach, and possible extensions to the C# type system that allow type-safe pattern matching on GADTs are discussed in the paper.

Collection type shapes

A collection type in this context refers to any type implementing IEnumerable, and this is further refined into enumerable and dictionary shapes:

public interface IEnumerableTypeShape<TEnumerable, TElement> : ITypeShape<TEnumerable>
{
    ITypeShape<TElement> ElementType { get; }

    Func<TEnumerable, IEnumerable<TElement>> GetGetEnumerable();
}

public interface IDictionaryTypeShape<TDictionary, TKey, TValue> : ITypeShape<TDictionary>
{
    ITypeShape<TKey> KeyType { get; }
    ITypeShape<TValue> ValueType { get; }

    Func<IReadOnlyDictionary<TKey, TValue>> GetGetDictionary();
}

A collection type is classed as a dictionary if it implements one of the known dictionary interfaces. Non-generic collections use object as the element, key and value types. As before, enumerable shapes can be unpacked by the relevant methods of TypeShapeVisitor:

public abstract partial class TypeShapeVisitor
{
    object? VisitEnumerable<TEnumerable, TElement>(IEnumerableTypeShape<TEnumerable, TElement> enumerableShape, object? state = null);
    object? VisitDictionary<TDictionary, TKey, TValue>(IDictionaryTypeShape<TDictionary, TKey, TValue> dictionaryShape, object? state = null);
}

Using the above we can now extend CounterVisitor so that collection types are supported:

partial class CounterVisitor : TypeShapeVisitor
{
    public override object? VisitEnumerable<TEnumerable, TElement>(IEnumerableTypeShape<TEnumerable, TElement> enumerableShape, object? _)
    {
        var elementCounter = (Func<TElement, int>)enumerableShape.ElementType.Accept(this)!;
        Func<TEnumerable, IEnumerable<TElement>> getEnumerable = enumerableShape.GetGetEnumerable();
        return new Func<TEnumerable, int>(enumerable =>
        {
            if (enumerable is null) return 0;
            
            int count = 0;
            foreach (TElement element in getEnumerable(enumerable))
                count += elementCounter(element);

            return count;
        });
    }

    public override object? VisitDictionary<TDictionary, TKey, TValue>(IDictionaryTypeShape<TDictionary, TKey, TValue> dictionaryShape, object? _)
    {
        var keyCounter = (Func<TKey, int>)dictionaryShape.KeyType.Accept(this);
        var valueCounter = (Func<TValue, int>)dictionaryShape.ValueType.Accept(this);
        Func<TDictionary, IReadOnlyDictionary<TKey, TValue>> getDictionary = dictionaryShape.GetGetDictionary();
        return new Func<TDictionary, int>(dictionary =>
        {
            if (dictionary is null) return 0;
            
            int count = 0;
            foreach (var kvp in getDictionary(dictionary))
            {
                count += keyCounter(kvp.Key);
                count += valueCounter(kvp.Value);
            }

            return count;
        });
    }
}

Enum types

Enum types are classed as a special type shape:

public interface IEnumTypeShape<TEnum, TUnderlying> : ITypeShape<TEnum> where TEnum : struct, Enum
{
    public ITypeShape<TUnderlying> UnderlyingType { get; }
}

The TUnderlying represents the underlying numeric representation used by the enum in question. As before, TypeShapeVisitor exposes relevant methods for consuming the new shapes:

public abstract partial class TypeShapeVisitor
{
    object? VisitEnum<TEnum, TUnderlying>(IEnumTypeShape<TEnum, TUnderlying> enumShape, object? state = null) where TEnum : struct, Enum;
}

Like before we can extend CounterVisitor to enum types like so:

partial class CounterVisitor : TypeShapeVisitor
{
    public override object? VisitEnum<TEnum, TUnderlying>(IEnumTypeShape<TEnum, TUnderlying> _, object? _)
    {
        return new Func<TEnum, int>(_ => 1);
    }
}

Optional types

An optional type is any container type encapsulating zero or one values of a given type. The most common example is System.Nullable<T> but it also includes the F# option types. It does not include nullable reference types since they constitute a compile-time annotation as opposed to being a real .NET type. Optional types map to the following shape:

public interface IOptionalTypeShape<TOptional, TElement> : ITypeShape<TOptional>
{
    // The shape of the value encapsulated by the optional type.
    ITypeShape<TElement> ElementType { get; }

    // Constructor delegates for the empty and populated cases.
    Func<TOptional> GetNoneConstructor();
    Func<TElement, TOptional> GetSomeConstructor();

    // Deconstructor delegate for optional values.
    OptionDeconstructor<TOptional, TElement> GetDeconstructor();
}

public delegate bool OptionDeconstructor<TOptional, TElement>(TOptional optional, out TElement value);

In the case of Nullable<T>, the type int? maps to an optional shape with TOptional set to int? and TElement set to int. The relevant TypeShapeVisitor method looks as follows:

public abstract partial class TypeShapeVisitor
{
    object? VisitOptional<TOptional, TElement>(IOptionalTypeShape<TOptional, TElement> optionalShape, object? state = null);
}

We can extend CounterVisitor to optional types like so:

partial class CounterVisitor : TypeShapeVisitor
{
    public override object? VisitOptional<TOptional, TElement>(IOptionalTypeShape<TOptional, TElement> optionalShape, object? _)
    {
        var elementCounter = (Func<TElement, int>)optionalShape.ElementType.Accept(this);
        var deconstructor = optionalShape.GetDeconstructor();
        return new Func<TOptional, int>(optional => deconstructor(optional, out TElement element) ? elementCounter(element) : 0);
    }
}

Union types

PolyType supports union types through the IUnionTypeShape abstraction. Currently two kinds of union types are supported:

  1. Polymorphic class or interface hierarchies declared via DerivedTypeShape attribute annotations and
  2. F# discriminated union types.

The shape abstraction for union types looks as follows:

public interface IUnionTypeShape<TUnion> : ITypeShape<TUnion>
{
    // The list of all registered union cases and their shapes.
    IReadOnlyList<IUnionCaseShape> UnionCases { get; }

    // The underlying shape for the base type, used as the fallback case.
    ITypeShape<TUnion> BaseType { get; }

    // Gets a delegate used to compute the union case index for a given value, or -1 if none is found.
    Getter<TUnion, int> GetGetUnionCaseIndex();
}

public interface IUnionCaseShape<TUnionCase, TUnion> : IUnionCaseShape
    where TUnionCase : TUnion
{
    // A unique string identifier for the union case.
    string Name { get; }

    // A unique integer identifier for the union case.
    int Tag { get; }

    // The underlying shape for the current union case.
    ITypeShape<TUnionCase> Type { get; }
}

And as before, TypeShapeVisitor exposes relevant methods for the two types:

public abstract partial class TypeShapeVisitor
{
    object? VisitUnion<TUnion>(IUnionTypeShape<TUnion> unionShape, object? state = null);
    object? VisitUnionCase<TUnionCase, TUnion>(IUnionCaseShape<TUnionCase, TUnion> unionCaseShape, object? state = null)
        where TUnionCase : TUnion;
}

Putting it all together, here's how we can extend our counter example to support union types:

partial class CounterVisitor : TypeShapeVisitor
{
    public override object? VisitUnion<TUnion>(IUnionTypeShape<TUnion> unionShape, object? _)
    {
        var getUnionCaseIndex = unionShape.GetGetUnionCaseIndex();
        var baseTypeCounter = (Func<Union, int>)unionShape.BaseType.Accept(this);
        var unionCaseCounters = unionShape.UnionCases
            .Select(unionCase => (Func<Union, int>)unionCase.Accept(this))
            .ToArray();

        return new Func<Union, int>(union =>
        {
            int index = getUnionCaseIndex(ref union);
            Func<TUnion, int> counter = index < 0 ? baseTypeCounter : unionCaseCounters[index];
            return counter(union);
        });
    }

    public override object? VisitUnionCase<TUnionCase, TUnion>(IUnionCaseShape<TUnionCase, TUnion> unionCaseShape, object? _)
    {
        var caseCounter = (Func<TUnionCase, int>)unionCaseShape.Type.Accept(this)!;
        return new Func<TUnion, int>(union => caseCounter((TUnionCase)union!));
    }
}

Surrogate types

PolyType lets users customize the shape of a given type by marshalling its data to a surrogate type. This is done by declaring an implementation of the IMarshaller<T, TSurrogate> interface on the type, which defines a bidirectional mapping between the instances of the type itself and the surrogate. Such types are mapped to the following abstraction:

public interface ISurrogateTypeShape<T, TSurrogate> : ITypeShape<T>
{
    // The shape of the surrogate type
    ITypeShape<TSurrogate> SurrogateType { get; }
    
    // The bidirectional mapping between T and TSurrogate
    IMarshaller<T, TSurrogate> Marshaller { get; }
}

public interface IMarshaller<T, TSurrogate>
{
    TSurrogate? ToSurrogate(T? value);
    T? FromSurrogate(TSurrogate? value);
}

And corresponding visitor method

public abstract partial class TypeShapeVisitor
{
    object? VisitSurrogate<T, TSurrogate>(ISurrogateTypeShape<T, TSurrogate> surrogateShape, object? state = null);
}

We can extend the counter example to surrogate types as follows:

partial class CounterVisitor : TypeShapeVisitor
{
    public override object? VisitSurrogate<T, TSurrogate>(ISurrogateTypeShape<T, TSurrogate> surrogateShape, object? _)
    {
        var surrogateCounter = (Func<TSurrogate, int>)surrogateShape.Accept(this)!;
        var marshaller = surrogateShape.Marshaller;
        return new Func<T, int>(t => surrogateCounter(marshaller.ToSurrogate(t)));
    }
}

To recap, the ITypeShape model splits .NET types into five separate kinds:

  • IObjectTypeShape instances which may or may not define properties,
  • IEnumerableTypeShape instances describing enumerable types,
  • IDictionaryTypeShape instances describing dictionary types,
  • IEnumTypeShape instances describing enum types and
  • IOptionalTypeShape instances describing optional types such as Nullable<T> or F# option types.
  • IUnionTypeShape instances describing union types such as polymorphic type hierarchies or F# discriminated unions.
  • ISurrogateTypeShape instances that delegate their shape declaration to surrogate types.

Constructing and mutating types

The APIs described so far facilitate algorithms that perform object traversal such as serializers, formatters and validators. They do not suffice when it comes to writing algorithms that perform object construction or mutation such as deserializers, mappers and random value generators. This section describes the constructs used for writing this class of algorithms.

Property setters

The IPropertyShape interface exposes strongly typed setter delegates:

public interface IPropertyShape<TDeclaringType, TPropertyType>
{
    Setter<TDeclaringType, TPropertyType> GetSetter();
    bool HasSetter { get; }
}

public delegate void Setter<TDeclaringType, TPropertyType>(ref TDeclaringType obj, TPropertyType value);

The setter is defined using a special delegate the accepts the declaring type by reference, ensuring that it has the expected behavior when working with value types. To illustrate how this works, here is a toy example that sets all properties to their default value:

public delegate void Mutator<T>(ref T obj);

class MutatorVisitor : TypeShapeVisitor
{
    public override object? VisitObject(IObjectTypeShape<T> objectShape, object? _)
    {
        Mutator<T>[] propertyMutators = objectShape.Properties
            .Where(prop => prop.HasSetter)
            .Select(prop => (Mutator<T>)prop.Accept(this)!)
            .ToArray();

        return new Mutator<T>(ref T value => foreach (var mutator in propertyMutators) mutator(ref value));
    }

    public override object? VisitProperty<TDeclaringType, TPropertyType>(IPropertyShape<TDeclaringType, TPropertyType> propertyShape, object? _)
    {
        Setter<TDeclaringType, TPropertyType> setter = propertyShape.GetSetter();
        return new Mutator<TDeclaringType>(ref TDeclaringType obj => setter(ref obj, default(TPropertyType)!));
    }
}

which can be consumed as follows:

ITypeShape<MyPoco> shape = provider.GetShape<MyPoco>();
MutatorVisitor visitor = new();
var mutator = (Mutator<MyPoco>)shape.Accept(visitor)!;

var value = new MyPoco { X = "X" };
mutator(ref value);
Console.WriteLine(value); // MyPoco { X =  }

struct MyPoco
{
    public string? X { get; set; }
}

Constructor shapes

While property setters should suffice when mutating existing objects, constructing a new instance from scratch is somewhat more complicated, particularly for types that only expose parameterized constructors or are immutable. PolyType models constructors using the IConstructorShape abstraction which can be obtained as follows:

public partial interface IObjectTypeShape<T>
{
    IConstructorShape? Constructor { get; }
}

public partial interface IConstructorShape<TDeclaringType, TArgumentState> : IConstructorShape
{
    int ParameterCount { get; }
    Func<TArgumentState> GetArgumentStateConstructor();
    Func<TArgumentState, TDeclaringType> GetParameterizedConstructor();
}

public abstract partial class TypeShapeVisitor
{
    object? VisitConstructor<TDeclaringType, TArgumentState>(IConstructorShape<TDeclaringType, TArgumentState> shape, object? state = null);
}

The constructor shape specifies two type parameters: TDeclaringType represents the declaring type of the constructor while TArgumentState represents an opaque, mutable token that encapsulates all parameters that will be passed to constructor. The choice of TArgumentState is up to the particular type shape provider implementation, but typically a value tuple is used:

record MyPoco(int x = 42, string y);

class MyPocoConstructorShape : IConstructorShape<MyPoco, (int, string)>
{
    public int ParameterCount => 2;
    public Func<(int, string)> GetArgumentStateConstructor() => () => (42, null!);
    public Func<(int, string), MyPoco> GetParameterizedConstructor() => state => new MyPoco(state.Item1, state.Item2);
}

The two delegates define the means for creating a default instance of the mutable state token and constructing an instance of the declaring type from a populated token, respectively. Separately, there needs to be a mechanism for populating the state token which is achieved using the IParameterShape interface:

public partial interface IConstructorShape<TDeclaringType, TArgumentState> : IConstructorShape
{
    IReadOnlyList<IParameterShape> Parameters { get; }
}

public partial interface IParameterShape<TArgumentState, TParameterType> : IParameterShape
{
    ITypeShape<TParameterType> ParameterType { get; }
    Setter<TArgumentState, TParameterType> GetSetter();
}

public abstract partial class TypeShapeVisitor
{
    object? VisitConstructor<TArgumentState, TParameterType>(IParameterShape<TArgumentState, TParameterType> shape, object? state = null);
}

Which exposes strongly typed setters for each of the constructor parameters. Putting it all together, here is toy implementation of a visitor that recursively constructs an object graph using constructor shapes:

class EmptyConstructorVisitor : TypeShapeVisitor
{
    private delegate void ParameterSetter<T>(ref T object);

    public override object? VisitObject<T>(ITypeShape<T> objectShape, object? _)
    {
        IConstructorShape? ctor = objectShape.GetConstructor();
        return ctor is null
            ? new Func<T>(() => default) // Just return the default if no ctor is found
            : ctor.Accept(this);
    }

    public override object? VisitConstructor<TDeclaringType, TArgumentState>(IConstructorShape<TDeclaringType, TArgumentState> constructorShape, object? _)
    {
        Func<TArgumentState> argumentStateCtor = constructorShape.GetArgumentStateConstructor();
        Func<TArgumentState, TDeclaringType> ctor = constructorShape.GetParameterizedConstructor();
        ParameterSetter<TArgumentState>[] parameterSetters = constructorShape.Parameters
            .Where(param => (ParameterSetter<TArgumentState>)param.Accept(this)!)
            .ToArray();

        return new Func<TDeclaringType>(() =>
        {
            TArgumentState state = argumentStateCtor();
            foreach (ParameterSetter<TArgumentState> parameterSetter in parameterSetters)
                parameterSetter(ref state);

            return ctor(state);
        });
    }

    public override object? VisitParameter<TArgumentState, TParameter>(IParameterShape<TArgumentState, TParameter> parameter, object? _)
    {
        var parameterFactory = (Func<TParameter>)parameter.ParameterType.Accept(this);
        Setter<TArgumentState, TParameter> setter = parameter.GetSetter();
        return new ParameterSetter<TArgumentState>(ref TArgumentState state => setter(ref state, parameterFactory()));
    }
}

We can now use the visitor to construct an empty instance factory:

ITypeShape<MyPoco> shape = provider.GetShape<MyPoco>();
EmptyConstructorVisitor visitor = new();
var factory = (Func<MyPoco>)shape.Accept(visitor)!;

MyPoco value = factory();
Console.WriteLine(value); // MyPoco { x = , y = 0 }

record MyPoco(int x, string y);

Constructing collections

Collection types are constructed somewhat differently compared to regular POCOs, using one of the following strategies:

  • The collection is mutable and can be populated following the conventions of C# collection initializers.
  • The collection can be constructed using a ReadOnlySpan of entries. Types declaring factories via the CollectionBuilderAttribute map to this strategy.
  • The collection can be constructed using an IEnumerable of entries. Typically reserved for immutable collections that expose a relevant constructor.
  • The collection type is not constructible.

These strategies are surfaced via the CollectionConstructionStrategy enum:

[Flags]
public enum CollectionConstructionStrategy
{
    None = 0,
    Mutable = 1,
    Span = 2,
    Enumerable = 4,
}

Which is exposed in the relevant shape types as follows:

public partial interface IEnumerableTypeShape<TEnumerable, TElement>
{
    CollectionConstructionStrategy ConstructionStrategy { get; }

    // Implemented by CollectionConstructionStrategy.Mutable types
    Func<TEnumerable> GetDefaultConstructor();
    Setter<TEnumerable, TElement> GetAddElement();

    // Implemented by CollectionConstructionStrategy.Span types
    SpanConstructor<TElement, TEnumerable> GetSpanConstructor();

    // Implemented by CollectionConstructionStrategy.Enumerable types
    Func<IEnumerable<TElement>, TEnumerable> GetEnumerableConstructor();
}

public delegate TEnumerable SpanConstructor<TElement, TEnumerable>(ReadOnlySpan<TElement> span);

Putting it all together, we can extend EmptyConstructorVisitor to collection types like so:

class EmptyConstructorVisitor : TypeShapeVisitor
{
    public override object? VisitEnumerable<TEnumerable, TElement>(IEnumerableTypeShape<TEnumerable, TElement> typeShape, object? _)
    {
        const int size = 10;
        var elementFactory = (Func<TElement>)typeShape.Accept(this);
        switch (typeShape.ConstructionStrategy)
        {
            case CollectionConstructionStrategy.Mutable:
                Func<TEnumerable> defaultCtor = typeShape.GetDefaultConstructor();
                Setter<TEnumerable, TElement> addElement = typeShape.GetAddElement();
                return new Func<TEnumerable>(() =>
                {
                    TEnumerable value = defaultCtor();
                    for (int i = 0; i < size; i++) addElement(ref value, elementFactory());
                    return value;
                });

            case CollectionConstructionStrategy.Span:
                SpanConstructor<TElement, TEnumerable> spanCtor = typeShape.GetSpanConstructor();
                return new Func<TEnumerable>(() =>
                {
                    var buffer = new TElement[size];
                    for (int i = 0; i < size; i++) buffer[i] = elementFactory();
                    return spanCtor(buffer);
                });

            case CollectionConstructionStrategy.Enumerable:
                Func<IEnumerable<TElement>, TEnumerable> enumerableCtor = typeShape.GetEnumerableConstructor();
                return new Func<TEnumerable>(() =>
                {
                    var buffer = new TElement[size];
                    for (int i = 0; i < size; i++) buffer[i] = elementFactory();
                    return enumerableCtor(buffer);
                });

            default:
                // No constructor, just return the default.
                return new Func<TEnumerable>(() => default!);
        }
    }
}

This concludes the tutorial for the core PolyType programming model. For more detailed examples, please refer to the PolyType.Examples project folder.