Specification
This document details how .NET types are mapped to type shapes. Both built-in shape providers (source generator and reflection provider) implement equivalent derivation logics that map arbitrary .NET types into type shapes of different kinds.
Overview
PolyType classifies .NET types into eight distinct type shape kinds, each represented by a specific interface:
- Object - IObjectTypeShape for general object types with properties and constructors.
- Enumerable - IEnumerableTypeShape for collection types implementing IEnumerable<T>
- Dictionary - IDictionaryTypeShape for key-value collection types.
- Enum - IEnumTypeShape for enum types.
- Optional - IOptionalTypeShape for nullable value types and F# options.
- Surrogate - ISurrogateTypeShape for types that define a marshaller to a surrogate type.
- Union - IUnionTypeShape for polymorphic type hierarchies, C# unions, or F# discriminated unions.
- Function - IFunctionTypeShape for delegate and F# function types.
Derivation Algorithm
PolyType maps types into individual shape kinds using the following rules:
Enum Types
A type is mapped to IEnumTypeShape if and only if it is an enum type.
Optional Types
A type is mapped to IOptionalTypeShape when:
- It is Nullable<T> (e.g.,
int?,DateTime?) or - It is an F# option type
Function Types
A type is mapped to IFunctionTypeShape when:
- It is a delegate type or
- It is an F# function type (i.e. any type deriving from
FSharpFunc<T,R>).
Surrogate Types
A type is mapped to ISurrogateTypeShape if and only if the type has been given an IMarshaler<TSource, TTarget> implementation to a surrogate type.
This is typically configured via the Marshaler property on the TypeShapeAttribute, GenerateShapeAttribute, or TypeShapeExtensionAttribute
attributes and doing so overrides the built-in shape kind inferred for the type.
Union Types
A type is mapped to IUnionTypeShape when:
- It is a class or interface with DerivedTypeShapeAttribute annotations or
- It is a class with
KnownTypeAttributeannotations or - It is a C# closed class with
InferClosedTypePolymorphismenabled or - It is a C# union type, or
- It is an F# union type, excluding representations handled by other shape kinds.
UnionKind identifies the representation as TypeHierarchy, CSharpUnion, or FSharpUnion. Explicit hierarchy configuration takes precedence over automatic C# union recognition. Existing kind and surrogate overrides continue to apply.
Closed-class hierarchy mapping
TypeShapeAttribute.InferClosedTypePolymorphism and TypeShapeExtensionAttribute.InferClosedTypePolymorphism
are opt-in settings with a public default of false. GenerateShapeAttribute and both
GenerateShapeForAttribute forms expose the same setting for source generation only. On
GenerateShapeForAttribute, it applies to the requested types rather than the witness. Their mapping rules are:
- An enabled setting requires a C# closed class. It must not be combined with a marshaler or an explicit
kind other than
TypeShapeKind.Union. These constraints are validated even when explicit registrations suppress inference. - Omitted settings do not conflict with explicit settings. Explicit
trueandfalsevalues from matching configuration sources must agree. DerivedTypeShapeAttributeregistrations take precedence overKnownTypeAttributeregistrations, which take precedence over inference. Suppressed inference produces a source-generator warning.- Inference reads each closed class's direct subclasses in compiler metadata order. A closed subclass is expanded recursively in place; a non-closed subclass is registered as a terminal case. Intermediate classes' own shape configuration does not affect ancestor inference.
- Case names use the existing PolyType type-name formatter. Tags are sequential and implicit, with
IsTagSpecified = false. No alphabetical sort is applied. Names and tags retain the existing uniqueness requirements; implicit identifiers have no stability guarantee across hierarchy changes. - Every inferred terminal case must be at least as accessible as the original requesting base, including containing-type restrictions. Abstract cases remain valid. Generic cases use the existing structural resolution rules. Inaccessible, unresolved, or unsupported cases are errors, not partial inference.
- A valid enabled setting always produces a
TypeHierarchyunion shape, including an empty hierarchy. Empty hierarchies have no cases and their index getter returns -1.BaseTypeis the contextual, non-union representation of the base. This mapping takes precedence over automatic C# union recognition.
C# case mapping
Case shapes describe payload types, not wrapper subtypes. A case marshaler binds to that case's creation member and must not redispatch construction using the argument's runtime type or a user-defined implicit conversion.
Metadata indices and inferred tags follow creation-member enumeration order; inferred tags have IsTagSpecified = false. Names use the existing type-name formatter. Duplicate names and specializations in which distinct declared cases become identical closed types are rejected. T and Nullable<T> remain distinct case types.
Enumeration order is an implementation detail; inferred identifiers have no cross-version stability guarantee. PolyType does not provide a C#-specific case-metadata attribute.
Source-generated case selection and extraction use C# union patterns, allowing the compiler to use the non-boxing access pattern when available. The reflection provider tries public instance, nongeneric bool TryGetValue(out TCase) overloads whose parameter types exactly match declared cases, most-specific-first, before falling back to Value. Union accessors must satisfy the language's well-formedness requirements: TryGetValue and HasValue must agree with Value.
The Value fallback uses a most-derived-first topological ordering. For example:
class Animal;
class Dog : Animal;
union AnimalUnion(Animal, Dog);
A Dog payload selects the Dog case even when created with new AnimalUnion((Animal)new Dog()). Undeclared runtime subtypes select their most specific compatible declared ancestor. Selection between multiple compatible cases with no assignability relationship is unspecified.
For a value type T, the overlap between T and T? is a special case of this rule: a non-null nullable value boxes as T and selects the T case when both are declared.
Null union references and null payloads select the first nullable case in metadata order. If no case admits null, the index getter throws as for a non-exhaustive switch, including for an empty native default value. An unmatched non-null payload also throws rather than returning the fallback index. These failures are InvalidOperationException instances (SwitchExpressionException where supported by the target framework). The C# base shape is empty and uninhabited, as for F# unions; supported values select a declared case.
IsNullable reflects the C# creation parameter's nullable contract. It is always false for F# and type-hierarchy cases, including F# cases with a null CLR representation and explicitly registered hierarchy base cases. These representations do not admit a nullable case payload.
Dictionary Types
A type is mapped to IDictionaryTypeShape when it implements:
- IDictionary<TKey, TValue> or
- IReadOnlyDictionary<TKey, TValue> or
- The non-generic IDictionary
Types implementing IDictionary use object to represent both key and value type shapes.
Construction Strategy
The construction strategy for dictionary types is inferred using the following priority:
- Types with public parameterless constructors that additionally expose
Addand indexer methods, or implement one of the mutableIDictionaryinterfaces are classified as Mutable. - Immutable or frozen dictionary types are classified as Parameterized.
- Types with public constructors accepting
ReadOnlySpan<KeyValuePair<K,V>>orIEnumerable<KeyValuePair<K,V>>parameters are classified as Parameterized. - Types annotated with
CollectionBuilderAttributeare classified as Parameterized. - Dictionary types not matching the above are classified as None.
PolyType will automatically select constructor or factory method overloads that additionally accept IEqualityComparer<Key> or IComparer<Key> and map those to the corresponding settings in the CollectionConstructionOptions<TKey> used by the constructor delegate.
For parameterless constructors it will additionally look for int capacity parameters that map to the Capacity property.
Enumerable Types
A non-dictionary type is mapped to IEnumerableTypeShape when:
- It implements IEnumerable<T> (except string) or
- It implements non-generic IEnumerable (using
objectas the element type) or - It implements
IAsyncEnumerable<T>or - It is an array type (including multi-dimensional arrays) or
- It is Memory<T> or ReadOnlyMemory<T> or
- It is an inline array or
- It is a struct containing a fixed size buffer as its only field.
Construction Strategy
The construction strategy for enumerable types is inferred using the following priority:
- Types with public parameterless constructors that additionally expose
Addmethods, or implement one of the mutableICollectioninterfaces are classified as Mutable. - Immutable or frozen collection types are classified as Parameterized.
- Types with public constructors accepting
ReadOnlySpan<TElement>orIEnumerable<TElement>parameters are classified as Parameterized. - Types annotated with
CollectionBuilderAttributeare classified as Parameterized. - Enumerable types not matching the above are classified as None.
Object Types
Types not matching any of the above categories map to IObjectTypeShape. This includes primitive types and other irreducible values such as string, DateTimeOffset, or Guid which map to the type shape trivially without including any property or constructor shapes.
Property Shape Resolution
Property shapes are resolved using the following criteria:
- Any public property or field is included as a property shape, unless explicitly ignored using a PropertyShapeAttribute.
- Non-public members are not included, unless they have been explicitly annotated with a PropertyShapeAttribute.
- Types annotated with
DataContractAttributeonly include members annotated withDataMemberAttributeor PropertyShapeAttribute. - Members from base types are included, with derived class members taking precedence over base class members with the same name (following the shadowing semantics of C#).
- Members whose type does not support being a generic parameter (pointers, ref structs) are always skipped.
Read-only fields and init-only properties do not expose a setter delegate.
Constructor Shape Resolution
Constructor shapes are resolved using the following algorithm:
Only public constructors are considered by default.
Prefer constructors annotated with ConstructorShapeAttribute, even if non-public.
If the type defines multiple public constructors, pick one that:
- Minimizes the number of required parameters not corresponding to any property shapes, then
- Maximizes the number of parameters that match read-only properties/fields, and then
- Minimizes the total number of constructor parameters.
Parameters correspond to a property shape if and only if they are of the same type and have matching names up to Pascal case/camel case equivalence.
If the selected constructor is parameterless and additionally there are no required or init-only properties defined on the type, it is mapped to an IConstructorShape that is parameterless and which relies on property shape setters to be populated.
Otherwise, the constructor gets mapped to a parameterized IConstructorShape. The logical signature of a parameterized constructor includes parameters and all settable members not corresponding to a constructor parameter, meaning that parameterized constructors DO NOT require additional binding from the object's property setter delegates.
Tuples
Tuple and value tuple types map to object shapes. Long tuple types, that is tuples with more than 7 elements are represented in IL using nested tuples. PolyType flattens long tuple representations so that all elements are accessible transparently from the outer tuple.
Method Shapes
Method shapes may be included in type shapes of any kind. By default, types do not include any method shapes and need to be opted in either by
- Configuring the
IncludeMethodsproperty in either of the TypeShapeAttribute, GenerateShapeAttribute, or TypeShapeExtensionAttribute or - Explicitly annotating a method with the MethodShapeAttribute.
The IncludeMethods approach only supports public methods, while MethodShapeAttribute can be used to opt in non-public methods.
Event Shapes
Event shapes may be included in type shapes of any kind. By default, types do not include any event shapes and need to be opted in either by
- Configuring the
IncludeMethodsproperty in either of the TypeShapeAttribute, GenerateShapeAttribute, or TypeShapeExtensionAttribute or - Explicitly annotating an event with the EventShapeAttribute.
Note
IsContextual identifies role-specific views, such as union bases and F# case bodies, which must not share ordinary type-keyed cache entries. Use TypeGenerationContext for recursive traversal: it evaluates contextual views separately while caching their ordinary child shapes.