Quick Introduction to C# Source Generators

Source generators were first introduced in C# 9 and let you run code at compile time to inspect your source and emit new files (code). In other words, you write some code that in turns generates code, for you! They run as part of the Roslyn compilation pipeline and are ideal for eliminating repetitive patterns such as property implementations or ToString methods.

Well, why would you want to do this? Here are some potential use-cases:

  • Auto-generate Data Transfer Objects (DTOs) for ferrying data instead of direct interaction with your database entity classes.
  • Auto-generate API clients.
  • Create validators.
  • Create ToString methods or fancy logging methods.

But we can do a lot of this using Reflection, no? You’re absolutely correct! And in most cases, that’s all you need. But, if you’re micro-optimizing your application and you are trying to cut all sorts of overhead, this maybe an option for you.

Reflection-based solution

Suppose you want every class to have a ToString() method that prints all property names and values. A reflection-based implementation might look like this:

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }

    public override string ToString()
    {
        var type = GetType();
        var props = type.GetProperties();
        var parts = props.Select(p => $"{p.Name}={{p.GetValue(this)}}");
        return $"{type.Name} {{ {string.Join(", ", parts)} }}";
    }
}

This approach uses reflection to inspect the type and get property names and values. It runs at runtime and incurs reflection overhead. It also must be written manually for every class.

Solving the same problem with a source generator

With source generators, you can write a generator that inspects classes marked with an attribute and emits a ToString() method at compile time. Here’s a simplified setup:

[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: (node, _) => node is ClassDeclarationSyntax cls &&
                                        cls.AttributeLists.Any(attr => attr.Attributes.Any(a => a.Name.ToString() == "AutoToString")),
                transform: (ctx, _) => (ClassDeclarationSyntax)ctx.Node)
            .Collect();

        context.RegisterSourceOutput(classDeclarations, (spc, classes) =>
        {
            foreach (var cls in classes)
            {
                var className = cls.Identifier.Text;
                var properties = string.Join(", ", cls.Members.OfType<PropertyDeclarationSyntax>()
                    .Select(p => $"{p.Identifier.Text}={{ {p.Identifier.Text} }}"));

                var source = $@"namespace {GetNamespace(cls)}
{{
    public partial class {className}
    {{
        public override string ToString() => $\"{className} {{ {properties} }}\";
    }}
}}";
                spc.AddSource($"{className}_ToString.g.cs", SourceText.From(source, Encoding.UTF8));
            }
        });
    }

    private static string GetNamespace(ClassDeclarationSyntax cls) =>
        (cls.Parent as NamespaceDeclarationSyntax)?.Name.ToString() ?? "Global";
}
  1. Use the generator in an app: create a console app (dotnet new console -n DemoApp), reference the generator project, and define classes with a partial declaration and the AutoToString attribute:
[AutoToString]
public partial class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

var person = new Person { FirstName = "Jane", LastName = "Doe" };
Console.WriteLine(person.ToString()); // Prints: Person { FirstName=Jane, LastName=Doe }

Because the ToString() method is generated at compile time, there is no reflection overhead at runtime and you don’t need to manually implement ToString() for every class.

Running the example

  1. Create the generator and console projects as described above.
  2. Add a project reference from DemoApp to ToStringGenerator and build. The generated file (e.g., Person_ToString.g.cs) appears under Dependencies → Analyzer in Visual Studio.
  3. Run the console app (dotnet run). The output should show the generated ToString() output.

Conclusion

Source generators are a powerful feature of the C# and .NET ecosystem that enable compile-time code generation. By inspecting user code and emitting additional files during compilation, they help automate repetitive patterns, improve performance, and surface errors early in the development cycle. Use sparingly. In. most cases, you can get away with a reflection-based solution. Also note that some old examples may lead you to the now older ISourceGenerator interface. Don’t use that. Instead, use the IIncrementalGenerator as I did in my example. The older one had some perf issues that were addressed by Microsoft in this newer interface.

Leave a Comment

Your email address will not be published. Required fields are marked *