Structs and Records
Classes aren't the only way to define types. Structs are value types: they're copied on assignment, like int. Records are types designed for data, with value-based equality, a readable ToString and easy copying with changes. Choosing between class, struct and record is an everyday C# decision.
Structs are value types
A struct looks like a class, but variables hold the data itself, not a reference. Assigning or passing a struct copies all of it:
var p1 = new PointStruct(1, 2); var p2 = p1; // a full copy p2.X = 100; Console.WriteLine($"{p1.X} {p2.X}"); var c1 = new PointClass(1, 2); var c2 = c1; // a copy of the reference c2.X = 100; Console.WriteLine($"{c1.X} {c2.X}"); struct PointStruct(int x, int y) { public int X { get; set; } = x; public int Y { get; set; } = y; } class PointClass(int x, int y) { public int X { get; set; } = x; public int Y { get; set; } = y; }
1 100 100 100
All the built-in numeric types, bool, char, DateTime, TimeSpan and Guid are structs. Structs can't inherit from other types (though they can implement interfaces) and can't be null unless declared nullable (lesson 43).
When to use a struct
Structs avoid a heap allocation, which matters when you create millions of small values. Microsoft's guidance is to use a struct only when the type:
- represents a single small value, like a point, a colour or an amount of money;
- is small (roughly 16 bytes or less), because it's copied every time it's passed;
- is immutable — mutable structs cause confusing bugs, since you often change a copy instead of the original.
Mark structs readonly to guarantee immutability:
var a = new Money(10.50m, "EUR"); var b = a.Add(new Money(4.25m, "EUR")); Console.WriteLine($"{a} + 4.25 = {b}"); readonly struct Money(decimal amount, string currency) { public decimal Amount { get; } = amount; public string Currency { get; } = currency; public Money Add(Money other) => other.Currency == Currency ? new Money(Amount + other.Amount, Currency) : throw new InvalidOperationException("Currency mismatch."); public override string ToString() => $"{Amount:F2} {Currency}"; }
10.50 EUR + 4.25 = 14.75 EUR
Records
A huge amount of code just carries data around: a customer, an order line, an API response. A record is built for that. The shortest form, a positional record, declares the properties in one line:
var p = new Person("Grace", "Hopper", 1906); Console.WriteLine(p); Console.WriteLine(p.FirstName); record Person(string FirstName, string LastName, int BirthYear);
Person { FirstName = Grace, LastName = Hopper, BirthYear = 1906 }
GraceFrom that one line, the compiler generates a constructor, public init-only properties, a helpful ToString, value equality, a deconstructor and support for with expressions.
Value equality
Two class objects are equal only if they're the same object. Two records are equal if all their properties are equal:
var r1 = new PersonRecord("Ada", 36); var r2 = new PersonRecord("Ada", 36); Console.WriteLine(r1 == r2); Console.WriteLine(ReferenceEquals(r1, r2)); var c1 = new PersonClass("Ada", 36); var c2 = new PersonClass("Ada", 36); Console.WriteLine(c1 == c2); var unique = new HashSet<PersonRecord> { r1, r2, new("Alan", 41) }; Console.WriteLine(unique.Count); record PersonRecord(string Name, int Age); class PersonClass(string name, int age) { public string Name { get; } = name; public int Age { get; } = age; }
True False False 2
Because equality and hash codes are based on the data, records work naturally as dictionary keys and in sets.
Non-destructive mutation with with
Record properties are immutable, so instead of changing a record you create a modified copy with a with expression:
var original = new Settings("dark", 14, true); var bigger = original with { FontSize = 18 }; var light = original with { Theme = "light", ShowLineNumbers = false }; Console.WriteLine(original); Console.WriteLine(bigger); Console.WriteLine(light); record Settings(string Theme, int FontSize, bool ShowLineNumbers);
Settings { Theme = dark, FontSize = 14, ShowLineNumbers = True }
Settings { Theme = dark, FontSize = 18, ShowLineNumbers = True }
Settings { Theme = light, FontSize = 14, ShowLineNumbers = False }Immutable data is easier to reason about: nothing can change it behind your back, and it's safe to share between threads.
Deconstruction
var order = new OrderLine("Notebook", 3, 2.49m); var (product, qty, price) = order; Console.WriteLine($"{qty} x {product} = {qty * price}"); record OrderLine(string Product, int Quantity, decimal Price);
3 x Notebook = 7.47
Adding members to records
Records can have extra properties, methods and validation, just like classes. Here the constructor parameter is validated before it becomes a property:
var item = new Product("Desk", 249m, 0.2m); Console.WriteLine(item.PriceWithTax); Console.WriteLine(item.IsExpensive()); var bad = new Product("", 10m, 0m); record Product(string Name, decimal Price, decimal TaxRate) { public string Name { get; } = string.IsNullOrWhiteSpace(Name) ? throw new ArgumentException("Name is required.") : Name; public decimal PriceWithTax => Price * (1 + TaxRate); public bool IsExpensive() => Price > 100m; }
298.8 True Unhandled exception. System.ArgumentException: Name is required.
Records support inheritance from other records too (record Employee(string Name, string Role) : Person(Name);).
record struct
record on its own creates a reference type (it's short for record class). record struct gives you the same conveniences on a value type; add readonly to make it immutable:
var a = new Coordinate(51.5072, -0.1276); var b = a with { Longitude = 2.3522 }; Console.WriteLine(a); Console.WriteLine(b); Console.WriteLine(a == new Coordinate(51.5072, -0.1276)); readonly record struct Coordinate(double Latitude, double Longitude);
Coordinate { Latitude = 51.5072, Longitude = -0.1276 }
Coordinate { Latitude = 51.5072, Longitude = 2.3522 }
TrueTuples
For quick, local groupings — returning two values from a method, say — you don't need to declare any type. A tuple is a lightweight value type with optional element names:
var range = (Min: 3, Max: 17); Console.WriteLine(range); Console.WriteLine(range.Max - range.Min); Console.WriteLine((1, "a") == (1, "a"));
(3, 17) 14 True
If a tuple starts being passed between many methods, promote it to a record so the shape has a name.
Choosing a type
| Use | When |
|---|---|
class | Objects with identity and behaviour that change over time: services, entities, controllers. |
record | Immutable data compared by value: DTOs, messages, configuration, results. |
readonly struct / readonly record struct | Small values created in large numbers: points, money, measurements. |
| tuple | Temporary groupings inside a method or a private helper. |
Try it yourself
Model a playing card as a record, build a small hand, and use with to create a modified card.
A of Hearts 10 of Spades 7 of Hearts A of Clubs True 2