Skip to content

Generator Pattern

A generator should be boring. It wires pipelines — it doesn't query symbols or build syntax trees. Keep the heavy lifting in the Projection and Writer layers where it belongs.

All examples are drawn from the Deepstaging source generator suite.

The Thin Generator

A generator's Initialize method does three things:

  1. Select symbols via ForAttribute<T>()
  2. Project them into models via the Projection layer
  3. Emit code via Writer extension methods
[Generator]
public sealed class StrongIdGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var models = context.ForAttribute<StrongIdAttribute>()
            .Map(static (ctx, _) => ctx.TargetSymbol
                .AsValidNamedType()
                .ToStrongIdModel(ctx.SemanticModel));

        context.RegisterSourceOutput(models, static (ctx, model) =>
        {
            model.WriteStrongId()
                .AddSourceTo(ctx, HintName.From(model.Namespace, model.TypeName));
        });
    }
}

That's the entire generator — 14 lines. The complexity lives in the Projection layer (model construction) and the Writer (code emission).

Multiple Pipelines

When a generator drives multiple features, register separate pipelines:

[Generator]
public sealed class EffectsGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Pipeline 1: Runtime classes
        var runtimes = context.ForAttribute<RuntimeAttribute>()
            .Map(static (ctx, _) => ctx.TargetSymbol
                .AsValidNamedType()
                .QueryRuntimeModel());

        context.RegisterSourceOutput(runtimes, static (ctx, model) =>
        {
            model.WriteRuntimeClass()
                .AddSourceTo(ctx, HintName.From(model.Namespace, model.TypeName));
            model.WriteRuntimeBootstrapperClass()
                .AddSourceTo(ctx, HintName.From(model.Namespace, $"{model.TypeName}Bootstrapper"));
            model.WriteRuntimeCapabilitiesInterface()
                .AddSourceTo(ctx, HintName.From(model.Namespace, $"I{model.TypeName}Capabilities"));
        });

        // Pipeline 2: Effects modules
        var modules = context.ForAttribute<EffectsModuleAttribute>()
            .Map(static (ctx, _) => ctx.TargetSymbol
                .AsValidNamedType()
                .QueryEffectsModules());

        context.RegisterSourceOutput(modules, static (ctx, models) =>
        {
            foreach (var model in models)
            {
                model.WriteEffectsModule()
                    .AddSourceTo(ctx, HintName.From(model.Namespace, model.TypeName));
            }
        });
    }
}

Combining Pipelines

When a generator needs data from multiple attribute-driven pipelines, use the multi-arity Combine overloads to flatten nested tuples. These auto-collect plural providers and return a flat tuple:

[Generator]
public sealed class DispatchGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var modules = context.ForAttribute<DispatchModuleAttribute>()
            .Map(static (ctx, _) => ctx.TargetSymbol.AsValidNamedType().QueryDispatchModule());

        var commandHandlers = context.ForAttribute<CommandHandlerAttribute>()
            .Map(static (ctx, _) => ctx.TargetSymbol.AsValidNamedType().QueryCommandHandlerGroup());

        var queryHandlers = context.ForAttribute<QueryHandlerAttribute>()
            .Map(static (ctx, _) => ctx.TargetSymbol.AsValidNamedType().QueryQueryHandlerGroup());

        // Flat tuple — no nesting, no manual .Collect() calls
        var combined = modules
            .Combine(commandHandlers, queryHandlers)
            .Select(static (tuple, _) =>
            {
                var (module, commands, queries) = tuple;
                return module with
                {
                    CommandHandlers = commands,
                    QueryHandlers = queries
                };
            });

        context.RegisterSourceOutput(combined, static (ctx, model) =>
        {
            model.WriteDispatchModule()
                .AddSourceTo(ctx, HintName.From(model.Namespace, model.ContainerName));
        });
    }
}

Without the overloads, Roslyn's Combine produces nested tuples that get increasingly painful to destructure:

// ❌ Without overloads — nested tuples, manual .Collect()
var combined = modules
    .Combine(commandHandlers.Collect())
    .Combine(queryHandlers.Collect())
    .Select(static (tuple, _) =>
    {
        var ((module, commands), queries) = tuple;  // (((A, B), C), D) with 4 providers
        ...
    });

// ✅ With overloads — flat tuple, auto-collect
var combined = modules
    .Combine(commandHandlers, queryHandlers)
    .Select(static (tuple, _) =>
    {
        var (module, commands, queries) = tuple;  // always flat
        ...
    });

Overloads are available for 2, 3, and 4 additional providers, on both IncrementalValuesProvider<T> (plural) and IncrementalValueProvider<T> (singular).

Static Output with Post-Initialization

Use RegisterPostInitializationOutput for code that doesn't depend on user source:

[Generator]
public sealed class PreludeGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(static ctx =>
        {
            ctx.AddSource("Prelude.g.cs", """
                // <auto-generated/>
                global using LanguageExt;
                global using LanguageExt.Common;
                global using static LanguageExt.Prelude;
                """);
        });
    }
}

Writer Classes

Writers are extension methods on model types that return OptionalEmit. They live in Writers/ subdirectories, organized by domain.

Basic Writer

// Writers/Ids/StrongIdWriter.cs
extension(StrongIdModel model)
{
    public OptionalEmit WriteStrongId()
    {
        var backingType = model.BackingTypeSnapshot;
        var valueProperty = PropertyBuilder
            .Parse($"public {backingType.FullyQualifiedName} Value {{ get; }}");

        return TypeBuilder
            .Parse($"{model.Accessibility} partial struct {model.TypeName}")
            .InNamespace(model.Namespace)
            .AddProperty(valueProperty)
            .AddConstructor(model)
            .ImplementsIEquatable(backingType, valueProperty)
            .ImplementsIComparable(backingType, valueProperty)
            .OverridesToString(
                model.BackingType == BackingType.String
                    ? $"{valueProperty.Name} ?? \"\""
                    : $"{valueProperty.Name}.ToString()",
                true)
            .AddFactoryMethods(model)
            .AddConverters(model, valueProperty)
            .Emit();
    }
}

Why OptionalEmit?

Emit() returns OptionalEmit which safely handles null models. The AddSourceTo extension only emits when the result is valid.

Composition with WithEach and If

Writers compose complex output using WithEach for collections and If for conditional sections:

extension(EffectsModuleModel model)
{
    public OptionalEmit WriteEffectsModule() => TypeBuilder
        .Parse($"public static partial class {model.TypeName}")
        .InNamespace(model.Namespace)
        .WithEach(model.Methods, WriteEffectMethod)
        .If(model.HasDbContext, b => b.AddDbSetQueries(model))
        .Emit();

    static TypeBuilder WriteEffectMethod(TypeBuilder builder, EffectMethodModel method) =>
        builder.AddMethod(MethodBuilder
            .Parse($"public static Eff<RT, {method.ReturnType}> {method.Name}<RT>()")
            .WithTypeConstraint("RT", model.ConstraintInterface)
            .WithBody(b => b
                .AddStatement(method.IsAsync
                    ? EffExpression.LiftIO().Async($"rt => rt.{model.PropertyName}.{method.Name}()")
                    : EffExpression.Lift().Sync($"rt => rt.{model.PropertyName}.{method.Name}()"))));
}

Delegating to Sub-Writers

For complex types, break the writer into focused helper methods:

extension(StrongIdModel model)
{
    public OptionalEmit WriteStrongId() => TypeBuilder
        .Parse($"{model.Accessibility} partial struct {model.TypeName}")
        .InNamespace(model.Namespace)
        .AddProperty(valueProperty)
        .AddConstructor(model)

        // Interface implementations — each is a separate extension method
        .ImplementsIEquatable(backingType, valueProperty)
        .ImplementsIComparable(backingType, valueProperty)
        .ImplementsIParsable(backingType)

        // Converters — conditionally added based on flags
        .AddConverters(model, valueProperty)
        .Emit();
}

Each .ImplementsIEquatable(), .AddConverters(), etc. is its own extension method on TypeBuilder, keeping each piece focused and testable.

Emit Best Practices

Parse for Complex Signatures

When a method or property has a non-trivial signature, use Parse instead of building piece by piece:

// Clear and readable
MethodBuilder.Parse("public static Eff<RT, Option<Attendee>> GetById<RT>(AttendeeId id)")
    .WithTypeConstraint("RT", "IHasWorkshopDb")
    .WithBody(b => b.AddStatement("..."));

Use TypeRef for Type-Safe References

// Type-safe wrappers — no magic strings
var returnType = new TaskTypeRef(TypeRef.Global("System.Collections.Generic.IReadOnlyList<Customer>"));
// Produces: Task<IReadOnlyList<Customer>>

Use Patterns for Common Implementations

The Emit layer includes pre-built pattern methods for common interface implementations:

TypeBuilder.Struct("OrderId")
    .ImplementsIEquatable(backingType, valueProperty)  // Equals, GetHashCode, ==, !=
    .ImplementsIComparable(backingType, valueProperty)  // CompareTo, <, >, <=, >=
    .ImplementsIParsable(backingType)                   // Parse, TryParse
    .ImplementsIFormattable(backingType, valueProperty) // ToString(format, provider)
    .Emit();