Skip to main content

Primary constructors are more than syntactic sugar

10/26/2026
3 minute read

Did you know that switching from a "regular" constructor to a primary constructor can change when your fields get initialized? Let's see why that matters.

In this short blog post, I will show you the difference and what the compiler does under the hood.

The problem#

Imagine you have a base class that does some work in its constructor, and that work calls a virtual member:

csharp
abstract class Base
{
    protected Base(Func<int?> check)
    {
        Console.WriteLine($"{GetType().Name}: check() = {check()}, Message = {Message}");
    }

    protected abstract string Message { get; }
}

Yes, yes - calling virtual members inside a constructor is a code smell. But in the real world, things are not always that obvious. In bUnit, the base constructor of a WaitForHelper starts a timer, and once that timer fires, it reads the virtual TimeoutErrorMessage. Same problem, just hidden behind a Timer and another thread.

Now let's derive from it the "classic" way:

csharp
class Regular : Base
{
    private readonly int? count;

    public Regular(int? count) : base(() => count)
    {
        this.count = count;
    }

    protected override string Message => $"count = {count?.ToString() ?? "null"}";
}

And the same thing with a primary constructor:

csharp
class Primary(int? count) : Base(() => count)
{
    protected override string Message => $"count = {count?.ToString() ?? "null"}";
}

Looks equivalent, right? Let's run both:

csharp
new Regular(3);
new Primary(3);
no-class
Regular: check() = 3, Message = count = null
Primary: check() = 3, Message = count = 3

Well - not so equivalent after all.

What the compiler does#

If we look at the lowered code (I used ILSpy, but sharplab.io does the job as well), the difference becomes obvious. Here is the Regular class:

csharp
public Regular(int? count)
{
    var obj = new <>c__DisplayClass1_0();
    obj.count = count;
    base..ctor(new Func<int?>(obj.<.ctor>b__0));
    this.count = obj.count;
}

The lambda captures the parameter in a closure (the display class), so check() sees the correct value. But the field this.count is only assigned after the base constructor returns. Everything the base constructor does in the meantime sees null.

And here is the Primary class:

csharp
public Primary(int? count)
{
    <count>P = count;
    base..ctor(new Func<int?>(<.ctor>b__0_0));
}

Because count is used inside a member (Message), the compiler captures it into a hidden field <count>P. And that field is assigned before the base constructor gets called. That is the same order as field initializers, which also run before base(...) in C#.

So in the bUnit case: with the "classic" constructor, if the timer fired before the derived constructor body ran, the error message said "did not result in any matching element(s)" instead of "did not result in exactly matching element(s)". With a primary constructor, the value is already there.

Small caveat#

The generated <count>P field is not readonly. You can reassign count inside your class, and the compiler won't stop you. If you need the readonly guarantee, you are back to declaring a field yourself - and a field initializer (private readonly int? count = count;) also runs before the base constructor. Just don't use the parameter in a member and in a field initializer at the same time. Then you have two copies of the same value, and the compiler warns you with CS9124.

An error has occurred. This application may no longer respond until reloaded.Reload x