Skip to content

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.Reflection with 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 and MemoryPack.
  • 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 → IO or T → T machinery and you care about NativeAOT, PolyType replaces both Reflection.Emit and System.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:

[GenerateShape<ThirdPartyDto>]
public partial class ShapeProvider;

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

[GenerateShape]
public record Person(string Name);   // CS0260-style error: missing partial

✅ Correct: partial so the generator can emit the other half

[GenerateShape]
public partial record Person(string Name);

❌ 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

var shape = Person.GetShape();   // generated; AOT-clean

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/init members.

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 ReflectionTypeShapeProvider in 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 via EmitCompilerGeneratedFiles.
  • ⚠️ 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.

Further reading