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.
permalinkHow 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.
permalinkHow 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.
permalinkParameter 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);
// ... }}
permalinkConstructor 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.
permalinkBehavior 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.
permalinkNaming 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';}
permalinkDifferent 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 };
permalinkWrap 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.