C# 9 introduced a new feature that allows you to inspect user code as it is being compiled and generate new C# source files that are added to the compilation. This enables you to write code that runs during compilation and produces additional source code based on the analysis of your program. In this blog post, I will explain what C# source code generators are, why they are useful, and how to use them in your projects.
What are C# source code generators?
A C# source code generator is a piece of code that you can write to hook into the compilation pipeline and generate additional source files that will be compiled. The process is like this one:
(From https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/)
When the compilation runs, the source code is passed to our code generator, which analyzes it and generates new code that is added to the current code and compiled with it, generating an executable that has both the user code and the generated one.
This allows you to make your code more performant and robust: one scenario in which it could be used is to remove the need for reflection in your code: all code is generated at compile time and you don't need to use reflection to work with the class data. Another use is to remove boilerplate code: you can add a generator to add all the extra code that's needed for a specific situation. For example, in my MVVM Toolkit 8 article I've shown the use of removing all the boilerplate code for the INotifyPropertyChanged
interface implementations. In fact, this code:
[ObservableProperty]
private Customer _selectedCustomer;
would create the property with a getter and setter, which triggers the PropertyChanged
event in the setter.
Creating a C# source code generator
We will now create a simple Code Generator. The source code generator is a .NET Standard 2.1 dll that will be added to the main project. To create the dll, you should use these commands:
dotnet new classlib -o HelloGenerator
cd HelloGenerator
code .
Add the packages Microsoft.CodeAnalysis.Analyzers
and Microsoft.CodeAnalysis.CSharp
:
dotnet add package Microsoft.CodeAnalysis.Analyzers
dotnet add package Microsoft.CodeAnalysis.CSharp
The csproj file must be changed to set the new target version and be used as a generator:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.1</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <LangVersion>latest</LangVersion> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.6.0" /> </ItemGroup> </Project>
In VS Code, create a new file and name it HelloGenerator.cs. The structure of the file is this:
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
[Generator]
public class HelloGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// Initialization code
}
public void Execute(GeneratorExecutionContext context)
{
// Code that will be executed at compile time
}
}
We will create a HelloGenerator
class that implements the ISourceGenerator
interface and add the [Generator]
attribute. This interface has two methods:
Initialize
, to initialize the generator. This method is called once, when your generator is loaded. You can use this method to register callbacks for syntax or semantic changes in the user code.Execute
, where the source code will be generated. TheGeneratorExecutionContext
parameter will provide information about the code being compiled.
Our sample generator looks like this:
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace HelloWorldGenerator;
[Generator]
public class HelloGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// No initialization required for this one
}
public void Execute(GeneratorExecutionContext context)
{
// Create the source to inject
var sourceBuilder = new StringBuilder(@"
using System;
namespace HelloWorldGenerated
{
public static class HelloWorld
{
public static void SayHello()
{
Console.WriteLine(""Hello from generated code!"");
}
}
}");
// Add the source file to the compilation
context.AddSource("HelloWorld", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
}
}
We don't need any initialization, and the execution will create a single file with a static class HelloWorld
that has a SayHello
method.
We can run dotnet build
to build the dll. Once you do it, we can create the code that uses it. Create a new console project and name it HelloTest
. In this project, we must reference the HelloGenerator
project:
cd ..
dotnet new console -o HelloTest
cd HelloTest
dotnet add reference ..\HelloGenerator\HelloGenerator.csproj
code .
In the HelloTest
project, we must change the reference for the generator project:
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <ProjectReference Include="..\HelloGenerator\HelloGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/> </ItemGroup> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net7.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>
The program for this project is:
HelloWorldGenerated.HelloWorld.SayHello();
When you run it, you get something like this:
As you can see, the source generator created a new class called HelloWorld
in a namespace called HelloWorldGenerated
, and added a static method called SayHello
that prints a message to the console. The user code then referenced this class and called its method.
This is a very simple example, but it demonstrates the basic steps of creating and using a source generator. In the next section, we will see how to write a more complex source generator that implements a specific scenario.
Json serializer generator
Let's say we have a class like this:
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
}
And we want to implement a method ToJson
, that will take an instance of the Person
class and generate a json string with the data. We could create an extension method, like this one:
using System.Text;
public static class JsonExtensions
{
public static string ToJson<T>(this T element)
{
var properties = typeof(T).GetProperties();
var builder = new StringBuilder();
builder.Append("{");
foreach (var property in properties)
{
var name = property.Name;
var value = property.GetValue(element);
builder.Append($"\"{name}\":\"{value}\",");
}
builder.Remove(builder.Length - 1, 1); // Remove trailing comma
builder.Append("}");
return builder.ToString();
}
}
This approach works fine, but it requires reflection, which has some performance overhead.
Another approach is to use a source generator, where you could write some code that analyzes the Person
class at compile time and generates a new class that implements a ToJson
method especially written for it, without having to resort to reflection. This approach is more performant, because everything is generated at compile time and there is no need to do anything at runtime.
At the end, this generator will produce a new file with the following extension methods:
public static class PersonExtensions
{
public static string ToJson(this Person person)
{
// Generate code to serialize person to JSON
}
public static Person FromJson(string json)
{
// Generate code to deserialize person from JSON
}
}
To write this generator, we need to do the same thing that we did in the previous project:
Create a new classlib project, add the packages Microsoft.CodeAnalysis.Analyzers
and Microsoft.CodeAnalysis.CSharp
and change the project file be used as a generator:
dotnet new classlib -o JsonGenerator
cd JsonGenerator
code .
dotnet add package Microsoft.CodeAnalysis.Analyzers
dotnet add package Microsoft.CodeAnalysis.CSharp
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <LangVersion>latest</LangVersion> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.6.0" /> </ItemGroup> </Project>
Create a new file JsonGenerator.cs and create a new class that implements the ISourceGenerator
interface and add the [Generator]
attribute to it.
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
[Generator]
public class JsonGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// Initialization code
}
public void Execute(GeneratorExecutionContext context)
{
// Code that will be executed at compile time
}
}
Implement the Initialize
method to register a callback for syntax receiver creation. This callback will create an instance of a custom syntax receiver class that will collect all the classes with the [Serializable]
attribute in the user code and create this class, that implements the ISyntaxReceiver
interface and overrides the OnVisitSyntaxNode
method. This method will be called for every syntax node in the user code and will check if it is a class declaration with the [Serializable]
attribute. If this is the case, it will be added to the CandidateClasses
list, that will be used in the Execute
method.
public class JsonGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// Register a callback for syntax receiver creation
context.RegisterForSyntaxNotifications(() => new SerializationSyntaxReceiver());
}
...
}
// A custom syntax receiver that collects all the classes with the [Serializable] attribute
public class SerializationSyntaxReceiver : ISyntaxReceiver
{
public List<ClassDeclarationSyntax> CandidateClasses { get; } = new List<ClassDeclarationSyntax>();
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// Check if the syntax node is a class declaration with the [Serializable] attribute
if (syntaxNode is ClassDeclarationSyntax classDeclaration &&
classDeclaration.AttributeLists.Count > 0 &&
classDeclaration.AttributeLists.Any(
al => al.Attributes.Any(a => a.Name.ToString() == "Serializable")))
{
// Add it to the candidate list
CandidateClasses.Add(classDeclaration);
}
}
}
Implement the Execute
method to generate the extension methods for each collected class using the syntax and semantic models provided by the context parameter.
public void Execute(GeneratorExecutionContext context)
{
// Get the compilation object that represents all user code being compiled
var compilation = context.Compilation;
// Get the syntax receiver that was created by our callback
var receiver = context.SyntaxReceiver as SerializationSyntaxReceiver;
if (receiver == null)
{
return;
}
// Loop through all the syntax trees in the compilation
foreach (var syntaxTree in compilation.SyntaxTrees)
{
// Get the semantic model for the syntax tree
var model = compilation.GetSemanticModel(syntaxTree);
// Loop through all the class declarations
foreach (var classDeclaration in receiver.CandidateClasses)
{
try
{
// Get the symbol for the class declaration
var classSymbol = model.GetDeclaredSymbol(classDeclaration) as INamedTypeSymbol;
if (classSymbol != null)
{
// Generate the extension method for the ToJson method
var source = GenerateSource(classSymbol);
// Add a new source file to the compilation with a unique hint name
context.AddSource($"{classSymbol.Name}.ToJson.cs", SourceText.From(source, Encoding.UTF8));
}
}
catch (System.Exception ex)
{
System.Console.WriteLine(ex);
}
}
}
}
This code will get the Compilation
object and loop through all the syntax trees, looking for classes that were selected in the Initialize
method. For each found class, it will call the GenerateSource
method and will write a new text file with the generated source:
private string GenerateSource(INamedTypeSymbol classSymbol)
{
// Get the name of the class
var className = classSymbol.Name;
// Get the properties of the class
var properties = classSymbol.GetMembers().OfType<IPropertySymbol>();
if (!properties.Any())
{
return "";
}
// Generate code for the ToJson method using StringBuilder
var builder = new StringBuilder();
builder.AppendLine($@"
using System.Text.Json;
public static class {className}Extensions
{{
public static string ToJson(this {className} {className.ToLower()})
{{");
// Append code to create a new JSON object as a string using string interpolation and escaping
builder.Append(@" return $""{{");
foreach (var property in properties)
{
builder.Append($@"\""{property.Name}\"":\""{{{className.ToLower()}.{property.Name}}}\"",");
}
builder.Remove(builder.Length - 1, 1); // Remove trailing comma
builder.Append(@"}}"";");
builder.Append(@$"
}}
public static {className} FromJson(string json)
{{");
// Append code to parse the JSON string as a JSON object using JsonDocument.Parse
builder.AppendLine($@"
using var document = JsonDocument.Parse(json);
var root = document.RootElement;");
// Append code to create a new instance of the class using its default constructor
builder.AppendLine($@"
var {className.ToLower()} = new {className}();");
// Append code to assign each property value from the JSON object using JsonElement.GetProperty and TryGet methods
foreach (var property in properties)
{
builder.AppendLine($@"
if (root.TryGetProperty(""{property.Name}"", out var {property.Name}Element))
{{
{className.ToLower()}.{property.Name} = {GetConversionCode(property.Type, $"{property.Name}Element")};
}}");
}
// Append code to return the created object as a result
builder.AppendLine($@"
return {className.ToLower()};
}}
}}");
// Return the generated source code as a string
return builder.ToString();
}
private string GetConversionCode(ITypeSymbol type, string value)
{
// Generate code to convert a JsonElement value to a given type using switch expression and JsonElement.Get methods
return type switch
{
INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_String => $"{value}.GetString()",
INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_Int32 => $"{value}.GetInt32()",
INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_Double => $"{value}.GetDouble()",
INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_Boolean => $"{value}.GetBoolean()",
INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_DateTime => $"{value}.GetDateTime()",
_ => throw new NotSupportedException($"Unsupported type: {type}")
};
}
This code will get the properties for the classes that have the [Serializable]
attribute and will generate a new static class with two methods, ToJson
and FromJson
, that will serialize and deserialize the class. For example, this code will generate this class for the Person class:
using System.Text.Json;
public static class PersonExtensions
{
public static string ToJson(this Person person)
{
return $"{{\"FirstName\":\"{person.FirstName}\",\"LastName\":\"{person.LastName}\",\"Age\":\"{person.Age}\"}}";
}
public static Person FromJson(string json)
{
using var document = JsonDocument.Parse(json) ;
var root = document.RootElement;
var person = new Person();
if (root.TryGetProperty("FirstName", out var FirstNameElement))
{
person.FirstName = FirstNameElement.GetString();
}
if (root.TryGetProperty("LastName", out var LastNameElement))
{
person.LastName = LastNameElement.GetString();
}
if (root.TryGetProperty("Age", out var AgeElement))
{
person.Age = AgeElement.GetInt32();
}
return person;
}
}
As you can see, the generated code is specific for the Person class and doesn't make use of reflection. To test this code, we can create a new project, JsonGeneratorTest and add the generator project as a reference:
cd ..
dotnet new console -o JsonTest
cd JsonTest
dotnet add reference ..\JsonGenerator\JsonGenerator.csproj
code .
And then change the csproj file to tell the referenced project is an analyzer:
<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <ProjectReference Include="..\JsonGenerator\JsonGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/> </ItemGroup> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net7.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project>
The code for the tester program is:
var person = new Person
{
FirstName = "John",
LastName = "Doe",
Age = 20
};
Console.WriteLine(person.ToJson());
var person1 = PersonExtensions.FromJson("{ \"FirstName\": \"Mary\", \"LastName\": \"Jane\", \"Age\": 30}");
Console.WriteLine(person1.ToJson());
[Serializable]
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public int Age { get; set; }
}
Once you run the code, you will get something like this:
As you can see, the generator has created the class and compiled it with the code and we can use it the same way as if we had written it ourselves. This saves us from writing boilerplate code and avoids the use of reflection.
The full source code for the article is at https://github.com/bsonnino/SourceGenerators