Skip to content

A Brief Discussion on C# Property Syntax Sugar and NRT Mechanism Evolution

TLDR

  • field keyword (C# 14): Allows direct access to the compiler-generated hidden field in auto-properties, enabling logic injection without reverting to manual backing fields.
  • Avoid confusion between => and { get; } =: => is dynamically calculated and executes on every call; { get; } = is a static cache that executes only once during object initialization.
  • NRT usability improvements: Through init and required keywords, the struggle of being forced to use null! or meaningless default values for DTOs under the NRT mechanism is resolved.
  • Enforced compilation checks: By setting <WarningsAsErrors>nullable</WarningsAsErrors>, NRT warnings can be elevated to compilation errors, ensuring consistent team contracts.
  • Collaboration between constructors and required: Using the [SetsRequiredMembers] attribute informs the compiler that the constructor has completed the assignment of required members, resolving conflicts with required.

The Evolution of C# Property Syntax

C# property syntax has undergone several evolutions aimed at simplifying definitions and reducing redundant code.

1. Early Classical Approach (Backing Field)

In the C# 1.0 era, properties required manual declaration of private fields to store data.

csharp
public class User {
    private string name;
    
    public string Name {
        get { return name; }
        set { name = value; }
    }
}

2. Auto-Implemented Properties

Introduced in C# 3.0, the compiler automatically generates the underlying field, suitable for data containers that do not require additional logic.

csharp
public class User {
    public string Name { get; set; }
}

3. Property Initializers

C# 6.0 allows assigning initial values directly when defining auto-properties.

csharp
public class User {
    public string Name { get; set; } = "Default Name";
}

4. Expression-bodied Properties

C# 6.0 and 7.0 introduced the => syntax, making property definitions more concise.

WARNING

When you might encounter issues: Confusing the execution timing of Expression-bodied (=>) and Property Initializer ({ get; } =).

  • public string Name => "Default Name": Recalculated every time it is read.
  • public string Name { get; } = "Default Name": Executed only once when the object is instantiated.

Incorrect Example:

csharp
public class Order {
    // Incorrect: Generates a new Guid every time it is read, causing issues with serialization or log tracking
    public Guid OrderId => Guid.NewGuid(); 

    // Correct: Generated only once during new()
    public Guid CorrectOrderId { get; } = Guid.NewGuid();
}

5. Semi-Auto Properties and the field Keyword

When you might encounter issues: When an auto-property needs to include minor logic in the set accessor (such as Trim() or change notification), one previously had to revert to a manually written backing field.

C# 14 introduces the field keyword to access the compiler-generated field directly:

csharp
public class User {
    public string Name { 
        get;
        set => field = value.Trim();
    }
}

TIP

It is recommended to prioritize logic processing in the set accessor to avoid the overhead of frequent calls in get, and to reduce potential issues when frameworks like Entity Framework Core access the backing field directly.


NRT (Nullable Reference Types) and Completion of Checking Mechanisms

NRT aims to explicitly declare whether a reference type can be null via ? annotation. To enforce this, you can set <WarningsAsErrors>nullable</WarningsAsErrors> in your project file.

Why was it often disabled in the past?

During the C# 8.0 to 10.0 era, if a DTO property was non-nullable, it had to provide a default value or use null! to deceive the compiler, which caused class definitions to bear contracts that could not be guaranteed.

Completion of the Mechanism

Through the following syntax, the development experience of NRT has been significantly improved:

  • init (C# 9.0): Ensures that properties can only be assigned during initialization, maintaining immutability.
  • required (C# 11.0): Forces the caller to assign a value during new(), eliminating the need to write null! inside the class.
csharp
public class UserDto {
    public required string UserName { get; init; }
}

// The caller must provide a value, otherwise compilation fails
UserDto dto = new() { UserName = "Alice" };

[SetsRequiredMembers]: Resolving Conflicts with Constructors

When you might encounter issues: When a class contains both a custom constructor and required properties, the compiler will issue a warning because it cannot be initialized via { }.

csharp
using System.Diagnostics.CodeAnalysis;

public class User {
    public required string UserName { get; init; }

    [SetsRequiredMembers]
    public User(string userName) {
        UserName = userName; 
    }
}

Application of required in Web API

In System.Text.Json serialization, if a property is marked as required but the frontend fails to send it, the system will throw a JsonException. This helps distinguish between "the frontend passed a default value" and "the frontend missed the field."


Change Log

  • 2026-03-30 Initial document creation.