PolyType & TypeShape
Key Points
- PolyType (Eirik Tsarpalis, 2024+) is a modern, source-generated type-shape provider for .NET — it gives library authors structural type metadata at runtime without reflection.
- It replaces a private dependency on
System.Reflectionwith a compile-time-emitted shape. Result: NativeAOT-safe, trim-safe, fast first-call. - It is the spiritual successor to TypeShape (the Eiriks-original prototype) and the design referenced by
System.Text.Json's source generator andMemoryPack. - Use case is library authoring: serializers, mappers, validators, structured cloners, equality, pretty-printers — anything that needs to walk an arbitrary type.
- For app code, you'll consume libraries that use PolyType internally (like the next-gen serializer/mapper/validator in your stack). You won't usually call PolyType APIs directly.
- 💡 If you're building any
T → IOorT → Tmachinery and you care about NativeAOT, PolyType replaces bothReflection.EmitandSystem.Reflection.
Concepts (deep dive)
What's a "type shape"?
A shape is a structural description of a type that a library can walk: kind (object/enum/dictionary/enumerable/nullable/tuple/union), properties (with getters/setters typed as Func/Action-like delegates), constructors, parameters, attributes, generic args.
The ITypeShape<T> interface is the visitor entry point. A library calls Visit and gets back a structured representation of T — without ever calling typeof(T).GetProperties().
┌──────────────────────────────────────┐
│ ITypeShape<T> │
│ Kind, Properties, Constructor, ... │
└──────────────────────────────────────┘
▲ ▲
│ source-gen │ reflection (dev only)
│ (AOT-safe) │ (fallback)
[GenerateShape] ReflectionTypeShapeProvider
partial record Person (handy for prototyping)
Why this matters: AOT + trimming
System.Reflection plus NativeAOT/trimming is a minefield:
typeof(T).GetProperties()→ properties may be trimmed if nothing references them statically.Activator.CreateInstance(type)→ the type's constructor may be trimmed.Expression.Compile()/Reflection.Emit→ not allowed under NativeAOT.
The traditional workaround is per-library source generators (System.Text.Json's [JsonSerializable], MemoryPack's [MemoryPackable], etc.). Each library reinvents the same wheel: walk the type tree at compile time, emit metadata, expose at runtime.
PolyType is one shared wheel. A library asks for ITypeShape<T>; the user marks T with [GenerateShape]; the source generator emits the shape; the library walks it. No reflection, no Reflection.Emit, no trimming warnings.
Minimal example
using PolyType;
[GenerateShape]
public partial record Person(string Name, int Age, Address Home);
public partial record Address(string City, string Country);
The source generator emits a IShapeable<Person> implementation alongside the type:
// Generated (sketch)
partial record Person : IShapeable<Person>
{
static ITypeShape<Person> IShapeable<Person>.GetShape()
=> SourceGenTypeShapeProvider.Default.GetShape<Person>();
}
A consuming library (a hypothetical structural printer):
public static string Print<T>(T value) where T : IShapeable<T>
{
var shape = T.GetShape();
var visitor = new PrinterVisitor();
var printer = (Func<T, string>)shape.Accept(visitor)!;
return printer(value);
}
// Usage
var p = new Person("Ada", 36, new Address("London", "UK"));
Console.WriteLine(Print(p)); // → Person { Name = Ada, Age = 36, Home = ... }
The visitor walks shape.Properties once and returns a compiled (non-reflective) closure. First call: visitor compiles a delegate. Every subsequent call: pure delegate invocation, no reflection.
The visitor
public sealed class PrinterVisitor : TypeShapeVisitor
{
public override object? VisitObject<T>(IObjectTypeShape<T> shape, object? state)
{
var props = shape.Properties
.Select(p => (Name: p.Name, Get: p.Accept(this) as Delegate))
.ToArray();
return new Func<T, string>(value =>
{
var sb = new StringBuilder($"{typeof(T).Name} {{ ");
// walk properties via emitted getter delegates
return sb.Append(" }").ToString();
});
}
public override object? VisitProperty<TDeclaring, TProperty>(
IPropertyShape<TDeclaring, TProperty> property, object? state)
{
var getter = property.GetGetter(); // Func<TDeclaring, TProperty>
return new Func<TDeclaring, string>(d => getter(d)?.ToString() ?? "");
}
// ... other Visit overrides for enum/dictionary/enumerable/nullable/etc.
}
property.GetGetter() returns a typed delegate generated at compile time — no MethodInfo.Invoke, no boxing.
Reflection-based provider (dev only)
For prototyping or dynamic types you don't own:
using PolyType.ReflectionProvider;
ITypeShape<Person> shape = ReflectionTypeShapeProvider.Default.GetShape<Person>();
⚠️ This pulls in System.Reflection and isn't AOT-safe. Use only for tooling/tests.
Comparison with System.Text.Json source gen
[JsonSerializable(typeof(Person))]
public partial class AppJsonContext : JsonSerializerContext { }
JsonSerializer.Serialize(person, AppJsonContext.Default.Person);
This is conceptually the same: a source generator emits per-type metadata. STJ's metadata is private to STJ — only the JSON serializer can use it. PolyType publishes the metadata as a generic interface so any library can consume it.
Comparison with MemoryPack / MessagePack-CSharp
| Library | Mechanism | Reuse? |
|---|---|---|
| MemoryPack | [MemoryPackable] source gen → MemoryPack-specific formatters | No |
| MessagePack-CSharp (source-gen mode) | [MessagePackObject] source gen → MsgPack formatters | No |
| System.Text.Json source gen | [JsonSerializable] → JsonTypeInfo | No |
| PolyType | [GenerateShape] → ITypeShape<T> | Yes — any library can consume |
PolyType doesn't replace MemoryPack/MessagePack; it could be the substrate that future versions of those libraries (and yours) build on, so users mark a type [GenerateShape] once instead of [MemoryPackable] + [MessagePackObject] + [JsonSerializable] + [GenerateMyMapper].
Comparison with reflection-based libraries
| Library | Approach | NativeAOT | Trim-safe | First-call cost |
|---|---|---|---|---|
| Newtonsoft.Json | Reflection + cache | ❌ | ❌ | High (reflect + emit) |
| AutoMapper | Reflection + Expression.Compile | ❌ | ❌ | High |
| FluentValidation (default) | Expression trees | Partial | Partial | Medium |
| System.Text.Json (reflection mode) | Reflection | ❌ | ⚠️ warnings | Medium |
| System.Text.Json (source-gen) | Source gen | ✅ | ✅ | Low |
| PolyType-based library | Source gen via shape | ✅ | ✅ | Low |
Walking a shape — kinds
switch (shape.Kind)
{
case TypeShapeKind.Object: /* visit IObjectTypeShape<T> */ break;
case TypeShapeKind.Enum: /* visit IEnumTypeShape<T,U> */ break;
case TypeShapeKind.Nullable: /* visit INullableTypeShape<T> */ break;
case TypeShapeKind.Enumerable:/* visit IEnumerableTypeShape<T,Element> */ break;
case TypeShapeKind.Dictionary:/* visit IDictionaryTypeShape<T,K,V> */ break;
}
Each kind exposes typed members (e.g. IEnumerableTypeShape<T,Element>.GetGetEnumerator() returns a typed Func<T, IEnumerable<Element>>).
Constructors and required members
var ctor = shape.GetConstructor(); // IConstructorShape<T, TArgState>
var argState = ctor.GetArgumentStateConstructor()(); // mutable parameter buffer
foreach (var p in ctor.Parameters)
{
var setter = p.GetSetter(); // Action<TArgState, TProperty>
setter(ref argState, JsonValue<...>(...));
}
var instance = ctor.GetParameterizedConstructor()(ref argState);
The "argument state" pattern lets serializers buffer parsed values before allocating the object — critical for required init-only properties.
When to adopt PolyType
✅ Build a serializer that targets multiple wire formats and needs AOT. ✅ Build a mapper (AutoMapper alternative) that should be trim-safe. ✅ Build a validator that walks complex object graphs at runtime without reflection. ✅ Build a structured logger / diff / equality / clone library. ✅ Build SDK code generators that need uniform type metadata.
❌ Regular app code — use the higher-level libraries (System.Text.Json, FluentValidation, Mapperly) instead. ❌ One-off serialization in a service — STJ source gen is enough. ❌ Anything that loads types only known at runtime (plugins) — PolyType is compile-time.
Interaction with NativeAOT
In csproj:
<PropertyGroup>
<PublishAot>true</PublishAot>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PolyType" Version="0.x.x" />
</ItemGroup>
[GenerateShape] types compile clean under AOT. The shape provider is statically reachable — the trimmer keeps everything.
For types you don't own (third-party DTOs), use [GenerateShape<TExternal>] on a partial witness type:
The provider class becomes the entry point for that external type's shape.
Source-generator interaction with analyzers
PolyType's generator runs alongside Roslyn analyzers (see Roslyn Analyzers in CI). The emitted partial type counts as "generated code"; analyzers should respect [GeneratedCode] and skip it.
⚠️ Heavy use of [GenerateShape] on dozens of types can lengthen build time. Inspect with <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> to see what's emitted.
TypeShape — the predecessor
TypeShape was the F#-flavoured prototype that proved the visitor-over-shape pattern. PolyType is the C#-first, source-generator-based evolution. If you read pre-2024 Eirik blog posts referencing TypeShape, mentally substitute "PolyType" for current usage.
Performance shape
Reflection-based PolyType-based
────────────────── ────────────────
First call: First call:
GetType ──── (no GetType)
GetProperties Visitor compiles delegate
PropertyInfo.Get*
MethodInfo.Invoke
→ ~100 µs → ~5 µs (visit + compile)
Subsequent: Subsequent:
cached Func cached Func
→ ~100 ns → ~100 ns
Memory: Memory:
~KB cached ~KB cached
per-type metadata per-type metadata
Steady-state perf converges. The win is the first call, the AOT story, and the lack of trimming warnings.
Code: correct vs wrong
❌ Wrong: reflection-based serializer in an AOT app
public static byte[] Serialize<T>(T value)
{
var props = typeof(T).GetProperties(); // trim-warning, AOT-fragile
foreach (var p in props)
Write(p.Name, p.GetValue(value)); // boxing, slow
return ms.ToArray();
}
✅ Correct: PolyType-based serializer
public static byte[] Serialize<T>(T value) where T : IShapeable<T>
{
var shape = T.GetShape();
var serializer = SerializerCache.GetOrAdd(shape); // visitor compiles once
return serializer(value);
}
❌ Wrong: forgetting partial on a [GenerateShape] type
✅ Correct: partial so the generator can emit the other half
❌ Wrong: using ReflectionTypeShapeProvider in a published AOT build
var shape = ReflectionTypeShapeProvider.Default.GetShape<Person>();
// AOT trim warnings; runtime exceptions on missing reflection metadata
✅ Correct: source-gen provider only
Design patterns for this topic
Pattern 1 — "Shape + Visitor"
- Intent: library walks type metadata structurally without reflection. Visitor returns a compiled delegate cached per type.
Pattern 2 — "Witness type for external DTOs"
- Intent:
[GenerateShape<TExternal>]on a partial witness class — generate shapes for types you don't own.
Pattern 3 — "Argument state for required members"
- Intent: buffer parsed parameter values, then construct in one shot. Supports
required/initmembers.
Pattern 4 — "Compile once, invoke many"
- Intent: visitor runs once per type at first use; result is a typed
Func<T, X>cached forever after.
Pattern 5 — "Shared substrate for multi-format libraries"
- Intent: one
[GenerateShape]per type powers a JSON serializer, a mapper, a validator, and a logger — instead of N decorations.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Source-generated shapes | AOT/trim-safe, fast first call | Build-time cost; partial required |
| Visitor pattern | Decouples shape from consumer | Verbose to author |
| Reflection provider | Zero-friction prototyping | Not AOT-safe |
| One attribute, many libraries | DRY across serializers/mappers/validators | Ecosystem still maturing (pre-1.0) |
| vs STJ source gen | Reusable across libraries | STJ already ships with .NET |
| vs Reflection.Emit | Trim/AOT-safe | No runtime IL escape hatch |
When to use / when to avoid
- Use when authoring a library that walks type structure: serializers, mappers, validators, equality, cloners, structured loggers, DTO contract diffing.
- Use when targeting NativeAOT or aggressive trimming and you need typed-property access.
- Avoid in regular application code — consume the higher-level libraries that use PolyType inside.
- Avoid for runtime-only types (plugins loaded after compile) — PolyType is compile-time.
- Avoid when an existing library already has a stable source generator (e.g. STJ) and you don't need cross-library reuse.
Interview Q&A
Q1. What problem does PolyType solve? Reflection is slow, AOT-incompatible, and trim-unsafe. Each serializer/mapper rebuilds its own source generator. PolyType is one shared "type-shape provider" that any library can consume.
Q2. What is ITypeShape<T>? A structured description of a type — kind (object/enum/etc.), properties with typed getter/setter delegates, constructor with parameter shapes. Visitable via Accept.
Q3. How does it differ from System.Text.Json source gen? STJ's metadata is private to STJ. PolyType publishes shape metadata as a generic interface so any library can consume — one source gen, many libraries.
Q4. Why must [GenerateShape] types be partial? The generator emits a half of the type with the IShapeable<T> implementation. Without partial the compiler can't merge.
Q5. What's the relationship to TypeShape? TypeShape (Eirik Tsarpalis, F#) was the prototype proving the shape+visitor pattern. PolyType is the C#-first, source-generator-based successor.
Q6. AOT story? First-class. Source-gen emits the shape; everything is statically reachable; no Reflection.Emit, no trimming warnings.
Q7. Reflection provider — when? Prototyping, tests, or types you don't own. Not for AOT/published apps.
Q8. How do you generate a shape for an external DTO? [GenerateShape<ExternalType>] on a partial witness class. The witness becomes the shape provider for that type.
Q9. Performance? First call: visitor compiles a typed delegate (~µs). Steady state: same as a hand-written delegate (~ns). No reflection on hot path.
Q10. Compared to MemoryPack / MessagePack-CSharp source-gen? MemoryPack/MsgPack source gens emit format-specific code. PolyType emits format-agnostic shape metadata — usable by any library.
Q11. When NOT to use PolyType? Regular app code (use STJ, Mapperly, FluentValidation). Plugin systems with runtime-discovered types. One-off codepaths where reflection cost is irrelevant.
Q12. Required/init properties? Argument-state pattern: parse parameters into a typed buffer, then call the parameterized constructor in one shot.
Gotchas / common mistakes
- ⚠️ Forgetting
partial— generator emits nothing (or compile error). - ⚠️ Using
ReflectionTypeShapeProviderin production — defeats the AOT goal. - ⚠️
[GenerateShape]on a non-public type — generator currently has visibility constraints; check the package readme. - ⚠️ Adopting in app code because it sounds cool — the value is in libraries.
- ⚠️ Heavy
[GenerateShape]use on huge object graphs — long build times. Inspect generated code viaEmitCompilerGeneratedFiles. - ⚠️ Mixing PolyType with reflection-based fallback in the same library — defeats AOT cleanliness. Pick one.
- ⚠️ Pre-1.0 surface — APIs may shift; pin a version in
Directory.Packages.props. - ⚠️ Confusing TypeShape (the F# prototype) with PolyType (the modern lib) in older blog posts.