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:
- Select symbols via
ForAttribute<T>() - Project them into models via the Projection layer
- 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();