Records
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
A record is a class or struct that the compiler fills in for you: value equality, a readable ToString, a
Deconstruct method and support for with expressions. Use one whenever a type’s identity is its data.
record class and record struct
// `record` alone means `record class` -- a reference type.
public record Person(string FirstName, string LastName);
// A value type.
public record struct Point(double X, double Y);
// The usual choice for small immutable value data: init-only properties.
public readonly record struct Coordinate(double Latitude, double Longitude);
record class |
record struct |
|
|---|---|---|
Semantics |
Reference |
Value |
Positional members are |
|
mutable properties (unless |
|
Type + all fields |
All fields |
Can be |
Yes |
No (but |
Supports inheritance |
Yes |
No |
Allocation |
Heap |
Inline / stack |
Positional Records
The parameter list is a primary constructor. On a record — unlike on a plain class — each parameter also becomes a public property:
public record Employee(string Name, string Department, decimal Salary)
{
// Add members in a body as usual.
public bool IsManager => Department == "Management";
// Extra constructors must chain to the primary one.
public Employee(string name) : this(name, "Unassigned", 0m) { }
// Additional properties need an initialiser or `required`.
public DateOnly? StartDate { get; init; }
}
public static class PositionalDemo
{
public static void Run()
{
var ada = new Employee("Ada", "Engineering", 120_000m) { StartDate = new(2026, 1, 1) };
Console.WriteLine(ada.Name); // synthesised property
Console.WriteLine(ada.IsManager);
Console.WriteLine(ada.StartDate);
}
}
A record without a parameter list is also fine when you prefer explicit properties:
public record Settings
{
public required string Environment { get; init; }
public int Retries { get; init; } = 3;
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
}
What the Compiler Synthesises
public record Person2(string FirstName, string LastName);
public static class SynthesisDemo
{
public static void Run()
{
var a = new Person2("Ada", "Lovelace");
var b = new Person2("Ada", "Lovelace");
// 1. Value equality -- Equals, GetHashCode, == and != all compare the members.
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False -- still two objects
Console.WriteLine(a.GetHashCode() == b.GetHashCode()); // True
// 2. A readable ToString built by PrintMembers.
Console.WriteLine(a); // Person2 { FirstName = Ada, LastName = Lovelace }
// 3. Deconstruct matching the positional parameters.
var (first, last) = a;
Console.WriteLine($"{first} {last}");
// 4. A copy constructor and Clone, which `with` uses.
var c = a with { LastName = "Byron" };
Console.WriteLine(c);
Console.WriteLine(a); // unchanged -- non-destructive
}
}
Every one of these can be replaced by writing it yourself; the compiler only supplies what you have not.
with Expressions
with copies the instance, then applies the listed initialisers to the copy:
public record Order2(string Customer, decimal Total, bool Paid)
{
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
public static class WithExpressions
{
public static void Run()
{
var order = new Order2("Ada", 99.50m, Paid: false);
var paid = order with { Paid = true };
var discounted = paid with { Total = paid.Total * 0.9m };
Console.WriteLine(order.Paid); // False -- the original is untouched
Console.WriteLine(discounted.Paid); // True -- copied through
Console.WriteLine(discounted.Total); // 89.55
Console.WriteLine(discounted.CreatedAt == order.CreatedAt); // True -- copied
}
}
The copy is shallow: a mutable reference-typed member is shared between the original and the copy. Keep
record members immutable (and prefer IReadOnlyList<T> or an immutable collection over List<T>) or with
will surprise you.
init-Only Properties
init is settable in an object initializer or a with expression, and read-only afterwards:
public record Config
{
public required string Host { get; init; }
public int Port { get; init; } = 443;
// An init accessor may validate, exactly like a set accessor.
public int Retries
{
get;
init => field = value < 0
? throw new ArgumentOutOfRangeException(nameof(value))
: value;
} = 3;
}
public static class InitDemo
{
public static void Run()
{
var config = new Config { Host = "example.com", Retries = 5 };
// config.Host = "other"; // error CS8852: init-only property
Console.WriteLine($"{config.Host}:{config.Port} x{config.Retries}");
}
}
init is not exclusive to records — any class or struct property can use it.
Record Inheritance and EqualityContract
A record class may derive from another record (but not from a plain class, and a plain class may not derive
from a record):
public abstract record Shape3(string Name);
public sealed record Circle3(double Radius) : Shape3("circle");
public sealed record Square3(double Side) : Shape3("square");
public static class RecordInheritance
{
public static void Run()
{
Shape3 a = new Circle3(2);
Shape3 b = new Circle3(2);
Shape3 c = new Square3(2);
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(c)); // False -- different runtime types
Console.WriteLine(a); // Circle3 { Name = circle, Radius = 2 }
var bigger = (Circle3)a with { Radius = 3 };
Console.WriteLine(bigger);
}
}
The type check is done by a synthesised protected virtual Type EqualityContract ⇒ typeof(…) property that
each record overrides. Because Equals compares EqualityContract first, a Circle3 never equals a Square3
even if every field matches — which is the behaviour a hand-written Equals usually gets wrong.
with on a base-typed variable returns the runtime type, because it calls the virtual Clone. That is why
the cast above is needed only to get at Radius, not to make the copy correct.
Customising ToString
Override PrintMembers to change what ToString prints while keeping the surrounding format, or override
ToString outright:
public record Secretive(string Name, string ApiKey)
{
protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append("Name = ").Append(Name).Append(", ApiKey = ****");
return true; // true means "I wrote something", so the braces are spaced
}
}
// new Secretive("svc", "s3cr3t").ToString() => Secretive { Name = svc, ApiKey = **** }
On a sealed record or a record struct, PrintMembers is private/readonly rather than protected virtual.
Records, Classes, Structs and Tuples
| Choose | When |
|---|---|
|
Small (≲ 16 bytes), immutable, value-equal data used often — coordinates, money, identifiers. |
|
Data of any size with value equality — DTOs, domain events, query results, configuration. |
|
The type has identity, mutable shared state, inheritance, or is a service rather than data. |
|
A value type needing custom (non-member-wise) equality or layout control. |
tuple |
A short-lived, local, unnamed grouping — a multi-value return, never a public API. |
A useful rule: if you find yourself writing Equals, GetHashCode and ToString by hand for a data type, you
wanted a record. If you find yourself giving a tuple element names in three different methods, promote it to a
record.
// Was: a tuple repeated everywhere
static (double Latitude, double Longitude) GetLocationTuple() => (40.4, -3.7);
// Became: a record struct with a name, methods and equality
public readonly record struct Location(double Latitude, double Longitude)
{
public static Location Origin => new(0, 0);
public override string ToString() => $"({Latitude:F4}, {Longitude:F4})";
}
Records in Pattern Matching
Positional records work directly with positional patterns, which is what makes them so good as the data half of a data-driven algorithm:
public abstract record Json
{
public sealed record Str(string Value) : Json;
public sealed record Num(double Value) : Json;
public sealed record Arr(IReadOnlyList<Json> Items) : Json;
}
public static class JsonRender
{
public static string Render(Json node) => node switch
{
Json.Str("") => @"""""",
Json.Str(var s) => $"\"{s}\"",
Json.Num(var n) and { Value: > 1000 } => $"{n:E2}",
Json.Num(var n) => n.ToString(CultureInfo.InvariantCulture),
Json.Arr({ Count: 0 }) => "[]",
Json.Arr(var items) => $"[{string.Join(",", items.Select(Render))}]",
_ => throw new NotSupportedException(),
};
}
See Pattern Matching.
See Also
-
Classes and Objects — primary constructors and properties in general.
-
Structs and Value Types —
record structin context. -
Equality and Operator Overloading — what the synthesised
EqualsandGetHashCodedo, and when to write your own. -
Tuples, Deconstruction and Anonymous Types — the lighter-weight alternative.