Modern C#
New C# versions cut repeated code and show errors earlier. Use record for data that is known by its value, pattern matching for readable decisions, and nullable to find null before run time. Know where each tool fits and where it has a trap.
Author: bezzad
The problem: a lot of code for a simple job
In our online shop, the order shipping address is written like this:
public class Address
{
public string City { get; set; }
public string PostalCode { get; set; }
public override bool Equals(object obj) =>
obj is Address other && City == other.City && PostalCode == other.PostalCode;
public override int GetHashCode() => HashCode.Combine(City, PostalCode);
}
This code has a few problems:
- A lot of repeated code. For simple data, we must write the Equals and GetHashCode methods by hand. If a field is added and we forget to add it to Equals, we have a bug.
- Anyone can change it. The address of a placed order must not change silently.
- The null value is hidden. Nothing says that City may be empty. The NullReferenceException error is found only in Production.
New C# versions have tools for exactly these problems. In this lesson we look at the most important ones. The latest stable version is C# 14, which came with .NET 10.
The record type: data that is known by its value
The same address, with record:
public sealed record Address(string City, string PostalCode);
The compiler does these jobs for us:
- It creates the properties. Both get a value only when the object is created (init). After that they do not change.
- It creates the Equals and GetHashCode methods. Two records are equal when all their fields are equal.
- It creates a readable ToString method. This is useful for logs.
- It gives you with. A copy with a few changes.
var home = new Address("Tehran", "1234567890");
var same = new Address("Tehran", "1234567890");
Console.WriteLine(home == same); // True
var office = home with { PostalCode = "1111111111" }; // a new object; home does not change
When record and when class?
record
- Data that is known by its value: an address, an amount of money, a date range.
- Messages and DTOs: API requests and responses, events.
- Data that must not change after it is created.
class
- Something that has an identity and changes over time: an order, a customer.
- Services: CheckoutService and similar.
- Entity models in EF Core. These models are known by their ID, not by comparing all fields.
If the data is small and you do not want it created on the Heap, use record struct. For example, an amount of money with two fields.
Primary Constructor in a class
From C# 12, a class can also take constructor parameters next to its name. This style is very common for services and DI:
public sealed class CheckoutService(ShopDb db, TimeProvider clock)
{
public async Task PlaceAsync(Order order, CancellationToken ct)
{
order.PlacedAt = clock.GetUtcNow();
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
}
}
required and init
For a class with no constructor, the required keyword says this property must get a value when the object is created. The init keyword says it can get a value only when the object is created:
public sealed class ProductDto
{
public required string Sku { get; init; }
public required decimal Price { get; init; }
public string? Description { get; init; }
}
var dto = new ProductDto { Sku = "P-907", Price = 129_500 }; // without Sku: compile error
The Nullable feature: finding null before run time
In new .NET projects, the Nullable option is on. From then on, every reference type has two forms:
- The string type means “never null”. If you put null in it, the compiler gives a warning.
- The string type with a question mark means “maybe null”. If you use it without a check, the compiler gives a warning.
The compiler follows the code path and knows where the value was checked:
public Customer? FindCustomer(Guid id) => db.Customers.Find(id);
var customer = FindCustomer(id);
Console.WriteLine(customer.Name); // warning CS8602: possibly null
if (customer is null)
return Results.NotFound();
Console.WriteLine(customer.Name); // OK: the compiler knows it is not null
So these warnings are not ignored, turn them into errors in the project file:
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
One important point: this check is only at compile time. At run time, data that comes from JSON or the database can still be null. So keep validating outside input.
Readable decisions with Pattern Matching
The shipping cost rule in our shop:
- Order of 2 million toman or more: free shipping.
- Package heavier than 30 kg: 250 thousand toman.
- Shipping in Tehran: 50 thousand toman.
- Other cities: 80 thousand toman.
With if and else, this rule spreads over several nested lines. With a switch expression, the code looks almost like the business sentences:
public static decimal ShippingCost(Order order) => order switch
{
{ Total: >= 2_000_000 } => 0,
{ WeightKg: > 30 } => 250_000,
{ Address.City: "Tehran" } => 50_000,
_ => 80_000,
};
A few kinds of pattern that we saw in this code:
- Property pattern. With curly braces, it checks the properties of the object. Even a nested property, like the city of the address.
- Relational pattern. With signs like greater than or less than.
- Discard pattern. The underscore means “anything else”.
You can combine patterns with the words and, or, and not. A list pattern also checks the shape of an array:
public static string Label(string[] tags) => tags switch
{
[] => "no tags",
["sale", ..] => "on sale",
[var single] => single,
_ => $"{tags.Length} tags",
};
if (order.Status is not (OrderStatus.Cancelled or OrderStatus.Refunded))
await SendInvoiceAsync(order, ct);
Collection Expressions
From C# 12, there is one simple form to create arrays, Lists and other collections: square brackets. The two-dot sign spreads one collection inside another:
int[] featured = [101, 233, 318];
List<int> homePage = [.. featured, 450, 512];
List<OrderLine> lines = []; // an empty list
New in C# 14
Extension Members
Before, you could only write extension methods. Now, with an extension block, you can also write extension properties:
public static class OrderExtensions
{
extension(Order order)
{
public bool HasFreeShipping => order.Total >= 2_000_000;
}
}
if (order.HasFreeShipping)
banner.Show("Free shipping!");
The field keyword
Before, if you wanted to write just one line of logic in a property setter, you also had to define a separate field. Now the field keyword points to the field that the compiler creates by itself:
public sealed class Product
{
public required string Name
{
get;
set => field = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Name is required.", nameof(value))
: value.Trim();
}
}
Null-conditional assignment
The question mark and dot operator now also works on the left side of an assignment. If the object is null, the assignment does not happen:
customer?.LastOrderAt = DateTimeOffset.UtcNow;
Important rules
- For data without identity, use record. For something that has an identity and changes, use class.
- Record comparison is shallow. Use a List inside a record with care.
- Keep the Nullable option on and take the warnings seriously. It is better to make them errors.
- Use the ! operator only when you are really sure and you know why.
- In a switch, the order of patterns matters. Put the more general pattern lower.
- Use a new feature only when it makes the code more readable. The goal is simpler code, not shorter code.
Common mistakes
| Mistake | Result | Right way |
|---|---|---|
| record for an Entity in EF Core | Comparison by all fields, strange behavior in change tracking. | A class with an ID. |
| A changeable List inside a record | Two records share one List and the comparison is wrong. | A read-only collection, or a class. |
| Turning off Nullable or ignoring the warnings | NullReferenceException in Production. | Keep it on and make the warnings errors. |
| Spreading ! around to quiet the compiler | The warning goes away, the problem does not. | A real null check. |
| A general pattern above a specific one in a switch | The lower lines never run. | Order from specific to general. |
| Assuming a primary constructor parameter does not change | Someone changes it in the middle of the class. | A separate readonly field. |
Summary in six lines
- The record type is for data that is known by its value. It creates Equals and with by itself.
- Record comparison is shallow. Use a collection inside it with care.
- The Nullable option shows null errors at compile time. The ! operator only hides them.
- A switch expression with patterns makes business rules readable. The order of patterns matters.
- Primary constructor parameters in a class are not readonly fields.
- C# 14 brought extension properties, the field keyword, and null-conditional assignment.