Source generators
Let the compiler write the boring code: using built-in generators (GeneratedRegex, JSON, LibraryImport, MVVM Toolkit) and building your own incremental generator.
INotifyPropertyChanged properties, ToString, mapping, serialisation. You could generate it at run time with reflection (slow, and it breaks trimming and AOT), or write it by hand (boring, and easy to get wrong). A source generator is a plugin for the C# compiler that reads your code while it compiles and adds new C# files to the build.
Generators you can use today
| Generator | You write | It generates |
|---|---|---|
[GeneratedRegex] | ||
[JsonSerializable] | JsonSerializerContext | |
[LibraryImport] | ||
[LoggerMessage] | ||
[ObservableProperty], [RelayCommand] | ICommands for WPF |
using System.Text.RegularExpressions; public static partial class Patterns{ [GeneratedRegex(@"^(IPE|HEA|HEB)(\d{2,4})$", RegexOptions.IgnoreCase)] public static partial Regex ISection();} Match m = Patterns.ISection().Match("ipe300");Console.WriteLine($"{m.Groups[1].Value.ToUpper()} depth {m.Groups[2].Value} mm");IPE depth 300 mm
[JsonSerializable(typeof(List<Beam>))]internal partial class ModelJsonContext : JsonSerializerContext { } string json = JsonSerializer.Serialize(beams, ModelJsonContext.Default.ListBeam); // no reflectionMVVM without boilerplate
// dotnet add package CommunityToolkit.Mvvmusing CommunityToolkit.Mvvm.ComponentModel;using CommunityToolkit.Mvvm.Input; public partial class BeamViewModel : ObservableObject{ [ObservableProperty] private string _mark = ""; // generates public string Mark { get; set; } with change notification [ObservableProperty] private double _lengthM; [RelayCommand] private void Check() => Status = LengthM > 24 ? "Too long" : "OK"; // generates CheckCommand [ObservableProperty] private string _status = "";}{Binding Mark} and {Binding CheckCommand}. The toolkit wrote the properties and the command for you.
Writing your own generator
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" /> </ItemGroup></Project>using System.Linq;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp.Syntax; [Generator]public class ToStringGenerator : IIncrementalGenerator{ public void Initialize(IncrementalGeneratorInitializationContext context) { // 1. add the [AutoToString] attribute itself to the user's project context.RegisterPostInitializationOutput(ctx => ctx.AddSource("AutoToStringAttribute.g.cs", "namespace Gen { [System.AttributeUsage(System.AttributeTargets.Class)] public sealed class AutoToStringAttribute : System.Attribute { } }")); // 2. find classes marked with it, and collect what we need var classes = context.SyntaxProvider.ForAttributeWithMetadataName( "Gen.AutoToStringAttribute", predicate: (node, _) => node is ClassDeclarationSyntax, transform: (ctx, _) => { var type = (INamedTypeSymbol)ctx.TargetSymbol; var props = type.GetMembers().OfType<IPropertySymbol>() .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && !p.IsIndexer) .Select(p => p.Name).ToArray(); string ns = type.ContainingNamespace.IsGlobalNamespace ? "" : type.ContainingNamespace.ToDisplayString(); return (Ns: ns, Name: type.Name, Props: new EquatableArray(props)); }); // 3. write one file per class context.RegisterSourceOutput(classes, (spc, c) => { string body = c.Props.Items.Length == 0 ? "\"\"" // no properties: still valid C# : string.Join(" + \", \" + ", c.Props.Items.Select(p => $"\"{p} = \" + {p}")); string nsLine = c.Ns == "" ? "" : $"namespace {c.Ns};"; // classes without a namespace spc.AddSource($"{c.Name}.ToString.g.cs", $$""" {{nsLine}} partial class {{c.Name}} { public override string ToString() => "{{c.Name}} { " + {{body}} + " }"; } """); }); }}EquatableArray is a small wrapper (not shown) that compares arrays by their contents. The generator is incremental: Roslyn caches each step and reruns it only when its input changes, so typing in the editor stays fast. That only works if the values passed between steps compare by value. That is why we use tuples of strings and an equatable array, never Roslyn symbols or syntax nodes.
Using it
<ItemGroup> <ProjectReference Include="..\Gen\Gen.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /></ItemGroup>using Gen; namespace SteelTools; [AutoToString]public partial class Beam // partial: the generator adds the other half{ public string Mark { get; set; } = ""; public double LengthM { get; set; }}Console.WriteLine(new SteelTools.Beam { Mark = "B12", LengthM = 6.5 }); // calls the generated ToString()Beam { Mark = B12, LengthM = 6.5 }Generator rules
Generators can only add code, never change or delete yours. That is why the target class must be partial.Use IIncrementalGenerator. The olderISourceGeneratorreruns on every keystroke and makes the IDE slow.Report problems with diagnostics ( spc.ReportDiagnostic) instead of throwing.Test generators with snapshot tests: compile sample code and compare the generated output.
[GeneratedRegex] to write Patterns.Grid() that matches grid labels like "A-3", "C-12" (one capital letter, a dash, 1–2 digits). Print whether "B-7", "b-7" and "AB-1" match.
Show the solution
public static partial class Patterns{ [GeneratedRegex(@"^[A-Z]-\d{1,2}$")] public static partial Regex Grid();} foreach (var s in new[] { "B-7", "b-7", "AB-1" }) Console.WriteLine($"{s}: {Patterns.Grid().IsMatch(s)}");B-7: True b-7: False AB-1: False
Remember
Source generators add C# code at compile time: fast, AOT-friendly, no reflection. Use the built-ins: [GeneratedRegex],[JsonSerializable],[LibraryImport],[LoggerMessage], MVVM Toolkit.Your own generator: netstandard2.0 library, IIncrementalGenerator,ForAttributeWithMetadataName,AddSource.Generators only add code, so targets must be partial; keep pipeline values equatable.