A primary constructor can make a short class easier to read, but its parameters can also become mutable object state. Before replacing a conventional constructor, decide whether each argument is only an input to initialization or a value that methods should retain.
This tutorial uses a counter to make that distinction visible. If constructors are new to you, read C# classes and conventional constructors first. For accessor syntax, see the properties lesson.
Primary constructors for ordinary classes and structs arrived in C# 12. These independent console programs were checked on October 22, 2026, using .NET SDK 10.0.402, targeting net10.0 with C# 14.0, nullable analysis enabled, and warnings treated as errors. Each C# block is a complete Program.cs; run it separately. The examples use C# 12 syntax and no newer language feature. See Microsoft’s primary-constructor tutorial and language-version configuration.
A method can keep a constructor parameter alive
The parameter list follows the class name. In this counter, both the getter and Add method use start after construction:
using System;
var counter = new Counter(10);
Console.WriteLine(counter.Value);
counter.Add(5);
Console.WriteLine(counter.Value);
Console.WriteLine(new Counter().Value);
public sealed class Counter(int start)
{
public int Value => start;
public void Add(int amount) => start += amount;
public Counter() : this(0) { }
}
Expected output:
10
15
0
The first read returns 10. Add changes start, so the next read returns 15. A separate counter created through the parameterless constructor starts at zero. The input is not automatically readonly.
Because instance members read or change start, the compiler captures it into the instance’s state. Hidden storage keeps the value available after the constructor returns. That storage does not turn start into a source-level field or property. The C# 12 primary-constructor design document (Markdown) describes capture and compiler-created storage; do not depend on generated field names.
Make the field explicit when it communicates the design
If you want readers to see exactly which member holds the changing count, initialize a field and use that field consistently:
using System;
var counter = new Counter(10);
counter.Add(5);
Console.WriteLine(counter.Value);
public sealed class Counter(int start)
{
private int _value = start;
public int Value => _value;
public void Add(int amount) => _value += amount;
}
Expected output:
15
Here start is used only by the field initializer. The continuing state is _value, and the compiler does not need a separate capture field for start. Choosing this form is a readability decision, not a performance claim. The primary-constructor design document (Markdown) distinguishes initializer-only use from capture in instance members.
An initializer and a captured parameter can diverge
A more subtle version initializes a property and also retains the parameter. CS9124 is normally a warning, so a default console project can run this deliberately problematic example without the pragma. The pragma silences the warning; it is included because these examples were compiled with warnings treated as errors:
#pragma warning disable CS9124
using System;
var counter = new SplitCounter(10);
counter.Add(5);
Console.WriteLine($"Initial={counter.Initial}; Current={counter.Current}");
public sealed class SplitCounter(int start)
{
public int Initial { get; } = start;
public int Current => start;
public void Add(int amount) => start += amount;
}
Expected output:
Initial=10; Current=15
Initial keeps the integer copied during initialization. Current reads the captured start, which Add has changed. The property and captured parameter are two separate places holding values; changing one does not update the other.
In this direct-copy pattern, removing the suppression produces CS9124: the parameter is captured and also copied into a property initializer. An unsuppressed version of this pattern was also checked: with warnings treated as errors, its build failed. Without that setting, CS9124 remains a warning. See the constructor diagnostic reference. Keep a snapshot only when your API needs it, and name it accordingly. If you wanted one current value, use the preceding field-based counter. Do not add a warning suppression as a routine fix.
The parameter is not a member named this.start
This complete program intentionally fails:
using System;
Console.WriteLine(new Counter(10).Value);
public sealed class Counter(int start)
{
public int Value => this.start;
}
The compiler reports CS1061 for this.start: Counter has no such member. The compiler also reports CS9113 because the actual start parameter is unread; this warning becomes an error when warnings are treated as errors. Inside the type, the parameter is available by its simple name, start. Outside it, callers use a member you explicitly declare, such as Value. An ordinary class primary constructor does not synthesize public properties for its parameters. Positional records have different rules. Microsoft’s primary-constructor rules explain this distinction.
Other class constructors must chain to the primary one
Removing the this(0) initializer from the first counter does not create an independent initialization path:
using System;
Console.WriteLine(new Counter().Value);
public sealed class Counter(int start)
{
public int Value => start;
public Counter() { }
}
This program fails with CS8862. A secondary class constructor must invoke the primary constructor directly or through another constructor. Restore the this(0) initializer shown in the first program. The constructor diagnostic reference documents this requirement.
Choose where the state should live
- Use the parameter directly in instance members when captured state makes the small type clear.
- Use it only to initialize an explicit field or property when that member should be the single place holding the value.
- When an initializer and an instance member both mention the parameter, check whether you intended two independent values.
For the counter, choose either the captured start or the explicit _value. That one decision makes later changes easier to review: every read and update should point to the state you intended. For a separate construction concern, our required-members tutorial explains what callers and constructors promise during initialization.