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:

C#
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;
}
Output
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:

C#
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}";
}
Output
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:

C#
var p = new Person("Grace", "Hopper", 1906);
Console.WriteLine(p);
Console.WriteLine(p.FirstName);

record Person(string FirstName, string LastName, int BirthYear);
Output
Person { FirstName = Grace, LastName = Hopper, BirthYear = 1906 }
Grace

From 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:

C#
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;
}
Output
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:

C#
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);
Output
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

C#
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);
Output
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:

C#
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;
}
Output
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:

C#
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);
Output
Coordinate { Latitude = 51.5072, Longitude = -0.1276 }
Coordinate { Latitude = 51.5072, Longitude = 2.3522 }
True

Tuples

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:

C#
var range = (Min: 3, Max: 17);
Console.WriteLine(range);
Console.WriteLine(range.Max - range.Min);
Console.WriteLine((1, "a") == (1, "a"));
Output
(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

UseWhen
classObjects with identity and behaviour that change over time: services, entities, controllers.
recordImmutable data compared by value: DTOs, messages, configuration, results.
readonly struct / readonly record structSmall values created in large numbers: points, money, measurements.
tupleTemporary 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.

Try it yourself · Program.cs
Edit the code, copy it into Program.cs and run it with dotnet run Program.cs (.NET 10 or later). How to set up .NET
Output
A of Hearts
10 of Spades
7 of Hearts
A of Clubs
True
2