Using Primary Constructors in C# 12

Michael Jolley - Nov 15 '23 - - Dev Community

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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) { }
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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);

    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .