Incremental Source Generators Done Right: The Equality Contract

Incremental Source Generators Done Right: The Equality Contract

The previous post on source generators was about the hidden costs — what generators take from you in build time, IDE responsiveness, and Hot Reload. The obvious follow-up question landed in my inbox more than once: fine, can we at least do it right?

Yes. But “right” is more specific than the documentation lets on. IIncrementalGenerator exists for exactly one reason: to make sure your generator does real work only when its inputs change. Most generators in the wild do not achieve that. They implement the right interface, they compile cleanly, they pass their snapshot tests — and they still redo the full generation work on every edit, because somewhere in the pipeline a value is compared by reference and the cache silently misses.

This post opens a four-part series on getting incrementalism right, and all four parts share one running example: [AutoToString], a small generator that gives a class a ToString override listing its public properties. This post uses its embryonic form to expose the equality contract; part 2 builds the generator properly.

The Mental Model: A Pipeline of Equality-Checked Values

Forget “a generator is a method that emits code.” An IIncrementalGenerator is a dataflow graph. Every node in the pipeline produces a value. The framework caches that value. On the next run, if the node’s input is equal to the cached input — compared with EqualityComparer<T>.Default — the node does not execute and every node downstream of it stays cached too.

That is the entire architecture. There is no second mechanism. The whole promise of incrementalism rests on your values answering Equals correctly. If your equality is wrong, your incrementalism is wrong, and nothing in the compiler will tell you.

Here is the smallest honest skeleton — the embryonic AutoToStringGenerator, before it knows anything about properties:

[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Node 1: one string per attributed class. Cached per value.
        IncrementalValuesProvider<string> classNames = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "AutoToString.AutoToStringAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, _) => ctx.TargetSymbol.Name);

        // Node 2: only re-runs for a class whose name actually changed.
        context.RegisterSourceOutput(classNames, static (spc, className) =>
            spc.AddSource($"{className}.AutoToString.g.cs", $$"""
                partial class {{className}}
                {
                    public override string ToString() => "{{className}} { }";
                }
                """));
    }
}

This one is bulletproof — as a caching skeleton, and for a boring reason: the only thing flowing between the nodes is a string. Strings have value equality. Type a character in an unrelated file, the transform produces the same string, EqualityComparer<string>.Default says equal, RegisterSourceOutput never runs. (As an AutoToString implementation it is deliberately embryonic: it knows only the class name, so the ToString it emits lists no properties, and because it ignores the containing namespace, the emitted partial only merges with classes in the global namespace. The marker attribute has to come from somewhere too — part 2 delivers it via RegisterPostInitializationOutput and fixes the rest.)

The finished generator does not get away with a single string: a ToString override worth generating needs the property names too. The moment you carry more than one piece of data per target, you reach for a record with a collection property — and that is where the contract quietly breaks.

The Default You Will Get Wrong First

The obvious next step for AutoToString is a record holding the class name and its public property names — the very model the finished generator caches. This first draft looks correct. It compiles. It even feels idiomatic:

// This looks fine. It is not.
public sealed record ClassModel(string Name, ImmutableArray<string> Properties);

A record gives you value equality on its members, so Name is fine. But ImmutableArray<T> does not implement value equality. It is a struct wrapping a reference to a T[], and its Equals compares that inner array reference — not the contents. Two arrays holding identical strings are not equal:

var a = ImmutableArray.Create("Id", "Name", "CreatedAt");
var b = ImmutableArray.Create("Id", "Name", "CreatedAt");

Console.WriteLine(a.SequenceEqual(b)); // True  - same contents
Console.WriteLine(a.Equals(b));        // False - different underlying arrays
Console.WriteLine(a == b);             // False - same reference comparison

Now follow the consequence through the pipeline. Your transform runs on every edit (that part is by design — Roslyn has to re-check what changed). Each run builds a fresh ImmutableArray<string> from the symbol’s members. Fresh array, fresh reference, Equals returns false, the record’s synthesized equality returns false, the cache misses, and everything downstream — including your string-building, formatting, AddSource — re-executes. Every single time. In every project that references your generator.

The generator is still “incremental” in the sense that it implements the interface. It is non-incremental in every sense that matters. And you will not notice until someone opens a binlog, like I did in the predecessor post, and finds your generator at the top of the RunGenerators list.

The Fix: EquatableArray<T>

The fix is a small wrapper that gives an array the equality semantics the pipeline needs. This is the centerpiece of part 1 — copy it into your generator project as-is:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;

/// <summary>
/// An immutable array with value-based equality, safe to use
/// in incremental generator pipeline models.
/// </summary>
public readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>, IEnumerable<T>
    where T : IEquatable<T>
{
    public static readonly EquatableArray<T> Empty = new(Array.Empty<T>());

    private readonly T[]? _array;

    public EquatableArray(T[] array) => _array = array;

    public EquatableArray(ImmutableArray<T> array)
        : this(Unsafe.As<ImmutableArray<T>, T[]?>(ref array) ?? Array.Empty<T>())
    {
    }

    public int Count => _array?.Length ?? 0;

    public ReadOnlySpan<T> AsSpan() => _array.AsSpan();

    public bool Equals(EquatableArray<T> other)
        => AsSpan().SequenceEqual(other.AsSpan());

    public override bool Equals(object? obj)
        => obj is EquatableArray<T> other && Equals(other);

    public override int GetHashCode()
    {
        // A default instance (null _array) is Equals-equal to Empty,
        // so it must hash like an empty array too.
        if (_array is null || _array.Length == 0)
        {
            return 17;
        }

        unchecked
        {
            var hash = 17;
            foreach (T item in _array)
            {
                hash = (hash * 31) + (item?.GetHashCode() ?? 0);
            }

            return hash;
        }
    }

    public IEnumerator<T> GetEnumerator()
        => ((IEnumerable<T>)(_array ?? Array.Empty<T>())).GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public static bool operator ==(EquatableArray<T> left, EquatableArray<T> right)
        => left.Equals(right);

    public static bool operator !=(EquatableArray<T> left, EquatableArray<T> right)
        => !left.Equals(right);

    public static implicit operator EquatableArray<T>(T[] array) => new(array);

    public static implicit operator EquatableArray<T>(ImmutableArray<T> array) => new(array);
}

Three details worth knowing before you paste:

  • Equals uses SequenceEqual over spans — element-by-element, no allocation. GetHashCode combines all elements, because a hash based on the array reference would be just as broken as the equality we started with.
  • The ImmutableArray<T> constructor uses Unsafe.As to reuse the inner array without copying. Generators target netstandard2.0, so you reference the System.Memory package — it provides the ReadOnlySpan<T> and AsSpan APIs the struct leans on, and it transitively brings in System.Runtime.CompilerServices.Unsafe for the cast. If the trick makes you nervous, array.ToArray() works too — you pay one copy per transform run, which is still infinitely cheaper than a permanent cache miss.
  • This is not an exotic pattern I invented. It is what Microsoft ships inside its own generators — the System.Text.Json source generator in dotnet/runtime carries an ImmutableEquatableArray<T> for precisely this reason, and the CommunityToolkit generators carry their own EquatableArray<T>. When the people who built the pipeline all wrap their arrays, take the hint.

The fixed model is a one-word change:

// Now the record's synthesized equality is correct all the way down.
public sealed record ClassModel(string Name, EquatableArray<string> Properties);

Strip to the Smallest Equatable Shape Early

The array trap is the most common break, but it is a special case of a general rule: everything that flows through the pipeline must be value-equatable, and it must be small. My rules for a pipeline model:

  • It is a record, so equality is synthesized member-wise.
  • Every member is a primitive, a string, an enum, another equatable record, or an EquatableArray<T> of those.
  • It contains no INamedTypeSymbol, no Compilation, no SyntaxNode, no SemanticModel. Ever.

Side by side, the broken shape and the one that survives caching:

// Broken: symbols are reference-equal across compilations.
// This cache can never hit after an edit.
internal sealed record BrokenTarget(
    INamedTypeSymbol Type,
    ImmutableArray<IPropertySymbol> Properties);

// Fixed: nothing but value-equatable members.
internal sealed record ClassModel(
    string Namespace,
    string Name,
    string AccessModifier,
    EquatableArray<string> Properties);

Compared with the two-member draft above, this shape has grown a namespace — emission needs it to write the namespace declaration — and an access modifier, which is here to make the general point: carry whatever the emitter needs, flattened to strings. Part 2’s final version slims the record back down to namespace, name, and properties, because that is all the ToString emitter actually consumes. The contract does not care how many members the record has, only that every one of them compares by value.

The conversion from symbol world to value world happens exactly once, inside the transform of ForAttributeWithMetadataName. That is the last point in the pipeline where a symbol is allowed to exist:

private static ClassModel Create(
    GeneratorAttributeSyntaxContext context,
    CancellationToken cancellationToken)
{
    var symbol = (INamedTypeSymbol)context.TargetSymbol;

    ImmutableArray<string> propertyNames = symbol
        .GetMembers()
        .OfType<IPropertySymbol>()
        .Where(p => p.DeclaredAccessibility == Accessibility.Public)
        .Select(p => p.Name)
        .ToImmutableArray();

    // Flatten ONCE. Nothing past this line ever sees a symbol again.
    return new ClassModel(
        symbol.ContainingNamespace.ToDisplayString(),
        symbol.Name,
        SyntaxFacts.GetText(symbol.DeclaredAccessibility),
        new EquatableArray<string>(propertyNames));
}

Yes, this transform re-runs whenever the attributed class is edited. That is fine — it is cheap, and it is supposed to run then. What matters is what happens on every other edit: the transform produces a ClassModel that is value-equal to the cached one, and the expensive downstream work — code building, formatting, emitting — never executes.

Why Symbols Poison the Cache

It is worth understanding why the symbol rule exists, because “just don’t” ages badly and the broken version looks so harmless:

// Looks like a convenient cache key. It is a slow memory leak
// wearing a cache miss as a disguise.
internal sealed record CachedModel(INamedTypeSymbol Type);
// roots: the symbol
//   -> its ContainingModule and ContainingAssembly
//   -> the Compilation that produced it
//   -> every syntax tree, metadata reference, and semantic
//      model bound to that compilation snapshot

Two independent problems, either of which is disqualifying.

First, correctness. ISymbol instances are reference-equal per compilation. Every keystroke produces a new Compilation, and the new compilation produces new symbol instances for the same declarations. Your CachedModel from the previous run holds the old symbol; the transform produces a new one; EqualityComparer<CachedModel>.Default compares them, the record delegates to the symbol’s reference equality, and the answer is false. Permanent cache miss, by construction. (Roslyn does have SymbolEqualityComparer.Default for comparing symbols deliberately — but the pipeline does not use it, and it would not fix the second problem anyway.)

Second, memory. The pipeline cache holds your model objects across runs — that is its job. If the model holds a symbol, the cache is now rooting the entire compilation snapshot the symbol came from, as the comment trail above spells out. Syntax trees, metadata references, semantic models: none of it can be collected while your “small cached record” is alive. Do this in a generator that ships in a popular package and you have built a memory leak into every Visual Studio and Rider instance that opens a consuming solution. Roslyn’s own incrementality guidance calls this out for the same reason: symbols and compilations must never be captured in pipeline state.

Strings are boring. Boring is the point. A ClassModel made of strings costs a few hundred bytes, compares in nanoseconds, and roots nothing.

The Contract

Here it is in one paragraph. Every value flowing through an IIncrementalGenerator pipeline is cached and compared with EqualityComparer<T>.Default; therefore every value must be a small, immutable, value-equatable shape — records of primitives and EquatableArray<T>, flattened from symbols exactly once inside the transform, with no ISymbol, Compilation, or SyntaxNode surviving past that point. Honor that and the framework does the caching for you. Break it anywhere, even in one property of one record, and your generator runs on every keystroke while looking perfectly incremental in the source.

That is the foundation. Everything that follows in this series — the shape of the pipeline, the test that proves the cache actually hits, the package that survives contact with consuming projects — builds on this one contract. Get it wrong here and no operator, test, or packaging trick downstream will save you.

Equality first. Everything else is downstream.

Comments

VG Wort