C#’s new non-record class and struct primary constructors can make your code cleaner and more concise, but if you’re not careful, they’ll ruin your app. Let’s talk about what they are, why you might want to start using them, and how to avoid the problems they can bring.
How Constructors Started
Let's start with a constructor that's familiar:
public class GeoPoint
{
private decimal _latitude;
private decimal _longitude;
public GeoPoint(decimal latitude, decimal longitude)
{
_latitude = latitude;
_longitude = longitude;
}
}
Our GeoPoint
class is constructed with two parameters that are used to set two private properties on the class. Of course, another common use case is dependency injection, where we inject an instance of a class to separate the concerns of this class from others.
public class AuthService
{
private readonly UserRepository _users;
public AuthService(UserRepository repository)
{
_users = repository
}
// ...
}
Regardless of the use case, the pattern is the same and it's what we've been writing in C# forever. But now primary constructors aim to simplify this code while also providing some interesting usage options.
How Primary Constructors Work
Let's take a look at our GeoPoint
class refactored to use primary constructors in C# 12.
public class GeoPoint(decimal latitude, decimal longitude) { }
The constructor method has been removed and its parameters have been added to the class declaration line. Already, you can see a reduction in lines of code, but what really makes primary constructors powerful is that the parameters they specify are in scope throughout the declaring type's entire body.
Parameter Scope
In this code snippet, we extend the GeoPoint
to include expression-bodied members that return the hemisphere and meridian of the object. Notice the parameters are referenced like private properties of the class. That's because they are essentially private properties. They aren't available publicly but can be passed to base constructors, used to set properties, or even used in functions or expression-bodied members.
public class GeoPoint(decimal latitude, decimal longitude)
{
public Hemisphere => latitude >= 0 ? 'N' : 'S';
public Meridian => longitude >= 0 ? 'E' : 'W';
}
This also means that our dependency-injected parameters look much cleaner as we don't have to explicitly define private properties.
public class AuthService(UserRepository users)
{
public bool IsLoggedIn(string username)
{
var user = users.GetUser(username);
// ...
}
}
Constructor Overloads
One thing you'll figure out quickly is once you've declared a primary constructor, your class will no longer have an implicit parameterless constructor. But fear not, you can always add constructor overloads with one condition: they must call the primary constructor using the this
keyword.
public class GeoPoint(decimal latitude, decimal longitude)
{
// Parameterless constructor with default values
public GeoPoint(): this(0, 0) { }
// Stay on the equator
public GeoPoint(decimal longitude): this(0, longitude) { }
public Hemisphere => latitude >= 0 ? 'N' : 'S';
public Meridian => longitude >= 0 ? 'E' : 'W';
}
Any additional constructors must call the primary constructor using the
this
keyword.
Behavior of Note
If you're like me, you're already thinking about how you can refactor old code to make use of this cleaner syntax, but there are a couple things you need to keep in mind before you start.
Naming Conflicts
Primary constructor parameter naming conflicts are allowed by the compiler with both fields and properties. When it occurs, the property or field will hide the primary constructor parameter. The only exception to that rule is property initialization.
public class GeoPoint(decimal latitude, decimal longitude)
{
decimal latitude;
// Uses the primary constructor to initialize the property.
public decimal Latitude { get; set; } = latitude;
// Uses the field rather than the primary constructor.
public Hemisphere => latitude >= 0 ? 'N' : 'S';
}
Different Than Record Primary Constructors
Unlike record types, public properties are not generated and because of this, the with
keyword cannot be used. Those using record types will want to account for this by explicitly declaring properties if they're needed.
public record GeoPointRecord(decimal latitude, decimal longitude) { }
public class GeoPointClass(decimal latitude, decimal longitude) { }
var point1 = new GeoPointRecord(50, 230);
var point2 = point1 with { Latitude = 100 };
var point3 = new GeoPointClass(50, 230);
// This line won't compile because no Latitude property exists.
var point4 = point3 with { Latitude = 100 };
Wrap Up
The new class and struct primary constructors introduced in C# 12 are a welcome addition to our syntax and allow us to write cleaner & more concise code, but for those with experience using primary constructors with record types, you'll need to be aware of some key differences.