In its younger years, every new version of C# brought concrete, factual features, whether LINQ, generics, or dynamic binding. At some point, the number of fundamental additions began to run out, and over the past decade, new versions have mostly added smart features like magic.
What does this mean?
Simply put, the compiler now accepts higher-level instructions for common programming tasks, reducing the number of lines the developer needs to write while ensuring the effectiveness and correctness of the compiled instructions.
This is what goes under the fancy name of syntactic sugar.
Example of Sugar
C# has plenty of example of syntactic sugar to offer. Most of the time, it is familiar code that you (or Cursor) write everyday and wanted (and asked for it over the years) to be as concise and direct as possible. The premium example is the declaration of a property in a class.
public string Title { get; set; }
For a long time in Java and in the early days of C# this syntax was not allowed and you had to specify all the nitty-gritty details of it.
private string _title;public string Title { get { return _title; } set { _title = value; }}
Another annoying example of code we have to write for safety reasons is checking objects for nullness. C# helps with the relatively recent null-coalescing operator.
var display = name ?? "N/A";
It means that the variable gets the value of the variable name and N/A if the variable points to a null reference.
var display = name is not null ? name : "N/A";
Other great examples are the null operator ?, the switch expression, pattern matching, async/await, var and even the from-select syntax of LINQ.
Record Types
A more recent example of syntactic sugar is record types, which provide a concise syntax for defining classes that are intended to be immutable and use values for equality checks. Record types are still classes—nothing more, nothing less—but they support an extremely concise syntax that makes them suitable for several compelling scenarios. For example, creating a simple data-transfer object can now take just one line of code!
public record Money(decimal Amount, string Currency);
Under the hood, the C# compiler emits a regular class with two properties: Amount and Currency. In this case, however, the two properties are not plain get/set properties but init-only. In other words, they get their values through the constructor and retain those values for the lifetime of the instance. Any attempt to modify the values of record properties results in a compiler error. As such, the “class” is designed to be immutable for its entire lifetime.
But the compiler does much more.
The generated class also provides overrides of Equals and GetHashCode and implements IEquatable<T>. Equals (and its companion GetHashCode) are overridable methods defined on the root .NET Object type and control equality and identity, respectively, of each instance. By default, Equals compares two instances by checking whether they reference the same memory location, while GetHashCode provides a value that uniquely identifies the instance across its lifetime. This allows instances to be safely used as keys in dictionaries and hash sets, as well as for grouping in LINQ queries.
Value-based Equality
Without records, if you want to compare two instances of, say, type Money by value so that two instances storing the same amount are considered equals, you have to write code like below, plus a mandatory override of GetHashCode.
public override bool Equals(object obj){ var other = obj as Money; if (other == null) return false; return Amount == other.Amount && Currency == other.Currency;}
If you have a record type instead these overrides (and more) come by default. In particular, as far as value-based equality is concerned the one-liner above for the type Money takes the computer to emit a class close to the following pseudo-code.
public class Money : IEquatable{ // Properties public decimal Amount { get; init; } public string Currency { get; init; } // Constructor public Money(decimal amount, string currency) { Amount = amount; Currency = currency; } // Equality contract for derived records protected virtual Type EqualityContract => typeof(Money); // Value-based Equals public virtual bool Equals(Money? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; if (EqualityContract != other.EqualityContract) return false; return Amount == other.Amount && Currency == other.Currency; } // Override object.Equals public override bool Equals(object? obj) { return Equals(obj as Money); } // GetHashCode combines all fields public override int GetHashCode() { return HashCode.Combine(EqualityContract, Amount, Currency); } // Operator overloads public static bool operator ==(Money? left, Money? right) { return Equals(left, right); } public static bool operator !=(Money? left, Money? right) { return !(left == right); } // Deconstruct method for positional records public void Deconstruct(out decimal amount, out string currency) { amount = Amount; currency = Currency; } // ToString override public override string ToString() { return $"Money {{ Amount = {Amount}, Currency = {Currency} }}"; }}
In particular, the compiler assigns the resulting class T the following aspects:
- Implementation of IEquatable<T> to avoid boxing and provide better performance when collections compare values
- Override of Equals and GetHashCode for compatibility. Equals is implemented using the methods of the IEquatable interface
- Support for == and != operators
- Support for tuple-style deconstruction
- Override of ToString that automatically prints fields in a readable way
- Equality contract for inheritance safety
The last is an interesting point. The line below ensures correct equality when records participate in inheritance hierarchies.
protected virtual Type EqualityContract => typeof(Money);
In summary, when you define a record type then parameters in the record declaration become init-only,immutableproperties. The definition of equality only compares these positional properties. Any other property you may define in the body of the record (called, non-positional) are not taken into account for equality. On the other hand, if you declare no positional properties in the constructor, then all properties are compared to determine equality in the Equals override.
Records and the Myth of Immutability
In literature, record types are often associated with the idea of being immutable objects. Is this correct? The short answer is: no.
A record is not inherently immutable, but its common positional representation—the one-liner example above—is. In fact, that’s not the only way to define a record. You can also write a record using an extended syntax, closer to that of a regular class, which allows you to control immutability and make selected (or all) properties mutable. This is where non-positional Records and the Myth of Immutability
In literature, record types are often associated with the idea of being immutable objects. Is this correct? The short answer is: no.
A record is not inherently immutable, but its common positional representation—the one-liner example above—is. In fact, that’s not the only way to define a record. You can also write a record using an extended syntax, closer to that of a regular class, which allows you to control immutability and make selected (or all) properties mutable. This is where non-positional properties come into play.come into play.
public record Person(string FirstName, string LastName){ // Non-positional property: derived value public int Age => DateTime.Today.Year - BirthDate.Year; // Non-positional property: optional additional info public DateTime? BirthDate {get; set;}}
Given this code, FirstName and LastName are immutable, Age is readonly by construction as a computed property, but BirthDate is a regular read/write property, which can alter the immutability of the instance.
However, using the readonly modifier on the record declaration turns all properties into init-only, unmodifiable properties that cannot be changed from outside or inside the record. In fact, even record methods (if any) are not allowed to modify anything on the this instance.
A record with complex properties exhibits what is referred to as shallow immutability, meaning nested properties are mutable if their types allow it. For example, a property of type List<T> is mutable, whereas a property of type IReadOnlyList<T> is not. To achieve deep immutability, you should always use records or immutable types for nested properties.
Finally, how do you expose a modified record? You use another piece of compiler magic: the with expression.
// Original instancevar person1 = new Person("Alice", "Smith", 30);// Create a new instance, changing only Agevar person2 = person1 with { Age = 31 };
This code returns a “copy” of the original record with modified values.
Summary
Records in C# make it easy to create value objects with value-based equality—two instances are equal if their data matches, not their references. They are immutable by default, support derived or optional properties, and can be combined with immutable collections for deep immutability. Features like with expressions let you create modified copies safely, making records ideal for robust, maintainable data models.