Records, Structs and Enums
Other kinds of types in C#: records with value equality and with-expressions, structs as value types, enums for named constants, and when to choose which.
Four kinds of types
Classes are the workhorse of C#, but not every type is best written as one. C# has three other kinds of user-defined types, each solving a specific problem:
- Records for data that is compared by content: two readings with the same meter, period and value are "the same".
- Structs for small values that should be copied, like numbers: a temperature or a GPS position.
- Enums for a fixed list of named options: ticket statuses or priorities.
The difference between classes and structs, reference types and value types, is introduced on Variables, Types and Operators; this page puts it to work.
Records
A record (C# 9) is a class or
struct that the compiler equips for holding data. The short
positional form declares the properties as parameters in
one line; the compiler generates a constructor, a public init-only
property for each parameter, value-based equality, a readable
ToString() and a Deconstruct method.
var a = new Reading("MTR-0042", "2026-10", 412.5);
var b = new Reading("MTR-0042", "2026-10", 412.5);
Console.WriteLine(a);
Console.WriteLine(a.Kwh);
Console.WriteLine(a == b);
Console.WriteLine(ReferenceEquals(a, b));
record Reading(string MeterId, string Period, double Kwh);
Reading { MeterId = MTR-0042, Period = 2026-10, Kwh = 412.5 }
412.5
True
False
Value equality
For a normal class, == asks whether two variables point
to the same object. A record uses
value equality instead: two
records are equal when they have the same type and all their
properties are equal. a and b above are
two separate objects (ReferenceEquals is
False), yet a == b is True.
The compiler generates Equals,
GetHashCode, == and != to
match, so records also work correctly as dictionary keys and in a
HashSet<T>.
Immutability and with expressions
The properties of a positional record class are init-only: they are
set when the record is created and are read-only after that. Because
a record cannot be changed, you make a changed
copy with a
with expression:
it copies every property of the original and then sets the ones you
list.
var original = new Reading("MTR-0042", "2026-10", 412.5);
var corrected = original with { Kwh = 412.8 };
Console.WriteLine(original);
Console.WriteLine(corrected);
Console.WriteLine(original == corrected);
record Reading(string MeterId, string Period, double Kwh);
Reading { MeterId = MTR-0042, Period = 2026-10, Kwh = 412.5 }
Reading { MeterId = MTR-0042, Period = 2026-10, Kwh = 412.8 }
False
var r = new Reading("MTR-0042", "2026-10", 412.5);
r.Kwh = 0;
record Reading(string MeterId, string Period, double Kwh);
example.cs(2,1): error CS8852: Init-only property or indexer 'Reading.Kwh' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Immutable data is easy to reason about: a reading you passed to a method cannot be changed behind your back, and it is safe to share between threads. The original stays available, which is exactly what you want for an audit trail of corrections.
var a = new Ticket("INC-40112", ["power", "leiden"]);
var b = new Ticket("INC-40112", ["power", "leiden"]);
Console.WriteLine(a == b);
a.Tags.Add("urgent");
Console.WriteLine(string.Join(", ", a.Tags));
record Ticket(string Number, List<string> Tags);
False power, leiden, urgent
For truly immutable data, use immutable members all the way down:
strings, numbers, other records, or read-only collections such as
IReadOnlyList<string> (which only prevents
changes through that reference) or the types in
System.Collections.Immutable.
Records with extra members
A record is still a full type: give it a body with methods and
computed properties. You can also skip the positional form and write
the properties yourself, which suits records with many properties,
optional values or required members.
var tariff = new Tariff("Green Fixed", 0.31m, 7.50m);
Console.WriteLine(tariff.MonthlyCost(250).ToString("F2"));
Console.WriteLine(tariff);
var c = new Customer { Name = "Mei Chen", City = "Utrecht" };
var moved = c with { City = "Leiden" };
Console.WriteLine(moved);
record Tariff(string Name, decimal PricePerKwh, decimal StandingCharge)
{
public decimal MonthlyCost(int kwh) => StandingCharge + kwh * PricePerKwh;
}
record Customer
{
public required string Name { get; init; }
public string City { get; init; } = "";
}
85.00
Tariff { Name = Green Fixed, PricePerKwh = 0.31, StandingCharge = 7.50 }
Customer { Name = Mei Chen, City = Leiden }
record on its own means record class, a
reference type. Record classes can inherit from other record
classes; equality then also checks that both objects have the same
run-time type. record struct (C# 10) is the value-type
version, covered with structs below.
Deconstruction
Deconstruction unpacks an
object into separate variables in one statement:
var (meter, period, kwh) = reading;. Positional records
support it out of the box, and so do tuples. Use the discard
_ for parts you do not need.
var reading = new Reading("MTR-0042", "2026-10", 412.5);
var (meter, period, kwh) = reading;
Console.WriteLine($"{meter} used {kwh} kWh in {period}");
var (_, _, onlyKwh) = reading;
Console.WriteLine(onlyKwh);
record Reading(string MeterId, string Period, double Kwh);
MTR-0042 used 412.5 kWh in 2026-10 412.5
Any type becomes deconstructable when it has a method named
Deconstruct with out parameters (see
Methods and Parameters).
Tuples returned from methods, and the key-value pairs of a
dictionary, deconstruct the same way, which makes
foreach loops over a dictionary read naturally:
var (min, max) = Range([18.5, 21.0, 16.5, 19.0]);
Console.WriteLine($"min {min}, max {max}");
var (c, f) = new Temperature(21.5);
Console.WriteLine($"{c} °C, {f} °F");
var homes = new Dictionary<string, string> { ["Mei Chen"] = "Utrecht" };
foreach (var (name, city) in homes)
Console.WriteLine($"{name} lives in {city}");
static (double Min, double Max) Range(double[] values) => (values.Min(), values.Max());
readonly struct Temperature(double celsius)
{
public double Celsius { get; } = celsius;
public double Fahrenheit => Celsius * 9 / 5 + 32;
public void Deconstruct(out double celsius, out double fahrenheit)
{
celsius = Celsius;
fahrenheit = Fahrenheit;
}
}
min 16.5, max 21 21.5 °C, 70.7 °F Mei Chen lives in Utrecht
Structs
A struct is a user-defined value
type. Like int, double and
DateOnly (which are structs themselves), a struct
variable holds the data directly instead of a reference to an object
elsewhere. Assigning a struct, or passing it to a method, copies all
of its fields.
var p1 = new PointStruct { X = 1, Y = 2 };
var p2 = p1;
p2.X = 9;
Console.WriteLine($"struct: p1.X = {p1.X}, p2.X = {p2.X}");
var c1 = new PointClass { X = 1, Y = 2 };
var c2 = c1;
c2.X = 9;
Console.WriteLine($"class: c1.X = {c1.X}, c2.X = {c2.X}");
struct PointStruct
{
public int X;
public int Y;
}
class PointClass
{
public int X;
public int Y;
}
struct: p1.X = 1, p2.X = 9 class: c1.X = 9, c2.X = 9
The same happens when a struct is passed to a method: the method works on its own copy. That is safe (a method cannot change your value by accident) but surprising if you expected the change to stick. For more on where values and objects live, see the stack and the heap.
var t = new Temperature(21.5);
Raise(t);
Console.WriteLine(t.Celsius);
static void Raise(Temperature temp)
{
temp.Celsius += 10;
Console.WriteLine($"inside: {temp.Celsius}");
}
struct Temperature(double celsius)
{
public double Celsius = celsius;
}
inside: 31.5 21.5
Readonly structs
Mutable structs cause exactly this kind of surprise, so most structs
should be immutable. Mark the struct
readonly: the compiler then requires every field to be
read-only and refuses any attempt to change the value afterward.
var t = new Temperature(21.5);
Console.WriteLine($"{t.Celsius} °C = {t.Fahrenheit} °F");
t.Celsius = 30;
readonly struct Temperature(double celsius)
{
public double Celsius { get; } = celsius;
public double Fahrenheit => Celsius * 9 / 5 + 32;
}
example.cs(3,1): error CS0200: Property or indexer 'Temperature.Celsius' cannot be assigned to -- it is read only
var points = new List<Position> { new() { Lat = 52.01, Lon = 4.36 } };
points[0].Lat = 52.16;
struct Position
{
public double Lat;
public double Lon;
}
example.cs(2,1): error CS1612: Cannot modify the return value of 'List<Position>.this[int]' because it is not a variable
Default values
A struct can never be null. Its default value, written
default, has every field set to zero, false or null;
you get it in a new array and in an uninitialized field. Make sure
"all zeros" is a sensible value for your struct, because no
constructor runs for it.
Temperature t = default;
Console.WriteLine(t.Celsius);
var week = new Temperature[3];
Console.WriteLine(week[2].Celsius);
readonly struct Temperature(double celsius)
{
public double Celsius { get; } = celsius;
}
0 0
Record structs
A record struct combines the two: a value type with the
generated ToString(), == and
with support of a record. Note one difference: the
positional properties of a plain record struct are
settable; write readonly record struct to make them
init-only, which is almost always what you want.
var station = new Position(52.01, 4.36);
var moved = station with { Latitude = 52.16 };
Console.WriteLine(station);
Console.WriteLine(moved);
Console.WriteLine(station == new Position(52.01, 4.36));
readonly record struct Position(double Latitude, double Longitude);
Position { Latitude = 52.01, Longitude = 4.36 }
Position { Latitude = 52.16, Longitude = 4.36 }
True
Enums
An enum (enumeration type) is a value
type that gives names to a fixed set of integer constants. Instead
of passing "in progress" around as a string, where a
typo compiles fine, you pass TicketStatus.InProgress,
and the compiler checks the name.
var status = TicketStatus.InProgress;
Console.WriteLine(status);
Console.WriteLine((int)status);
Console.WriteLine(status == TicketStatus.InProgress);
Console.WriteLine(status > TicketStatus.New);
enum TicketStatus
{
New,
Assigned,
InProgress,
Resolved,
Closed,
}
InProgress 2 True True
The members are numbered from 0 unless you give them values, and the
underlying type is int by default. Printing an enum
gives its name; a cast gives its number. Because an enum value is
really a number, the compiler does not let you assign a plain
int without an explicit cast (only the constant
0 converts implicitly).
Enums with explicit values and switch
Give members explicit values when the numbers mean something outside the program, such as priority 1 to 4 in the ticket system or a code stored in a database. A switch expression then maps each member to its behavior (see Control Flow and Pattern Matching).
foreach (var p in Enum.GetValues<Priority>())
Console.WriteLine($"{p} = {(int)p}: respond within {ResponseHours(p)} h");
static int ResponseHours(Priority p) => p switch
{
Priority.Critical => 1,
Priority.High => 4,
Priority.Medium => 8,
Priority.Low => 24,
_ => throw new ArgumentOutOfRangeException(nameof(p)),
};
enum Priority
{
Critical = 1,
High = 2,
Medium = 3,
Low = 4,
}
Critical = 1: respond within 1 h High = 2: respond within 4 h Medium = 3: respond within 8 h Low = 4: respond within 24 h
Keep the final _ arm: an enum variable can hold any
number of the underlying type, not only the named ones, so without
it the compiler warns (CS8524) and an unnamed value would throw at
run time.
Parsing and validating enums
Text from a user, a file or an API is converted with
Enum.TryParse, which returns false for
unknown names (or Enum.Parse, which throws). Watch out
for two traps: a cast or a parse accepts any number, and
the result is not checked against the named members. Use
Enum.IsDefined when the value comes from outside.
Console.WriteLine(Enum.TryParse<Priority>("High", out var p1) + " " + p1);
Console.WriteLine(Enum.TryParse<Priority>("urgent", out var p2) + " " + p2);
Console.WriteLine(Enum.TryParse<Priority>("high", ignoreCase: true, out var p3) + " " + p3);
Console.WriteLine(Enum.TryParse<Priority>("7", out var p4) + " " + p4);
Console.WriteLine(Enum.IsDefined(p4));
var odd = (Priority)42;
Console.WriteLine(odd);
enum Priority
{
Critical = 1,
High = 2,
Medium = 3,
Low = 4,
}
True High False 0 True High True 7 False 42
Flags enums
A [Flags] enum uses powers of two (1, 2, 4, 8 …) so
that several members fit in one value, each as one bit. Combine them
with |, test with HasFlag or
&, and remove one with & ~. This
is how .NET represents file attributes and access rights, and the
bit operations are the same as for
binary numbers.
var engineer = Permission.Read | Permission.Write;
Console.WriteLine(engineer);
Console.WriteLine(engineer.HasFlag(Permission.Write));
Console.WriteLine(engineer.HasFlag(Permission.Approve));
engineer |= Permission.Approve;
engineer &= ~Permission.Write;
Console.WriteLine(engineer);
Console.WriteLine((int)engineer);
[Flags]
enum Permission
{
None = 0,
Read = 1,
Write = 2,
Approve = 4,
Admin = 8,
}
Read, Write True False Read, Approve 5
The [Flags] attribute is what makes
ToString() print Read, Write instead of
the number 3. The values themselves are your responsibility: the
compiler does not check that they are powers of two.
Choosing the right kind of type
| Kind | Copy and equality | Use it for |
|---|---|---|
class |
Reference type; == compares identity (unless
overloaded)
|
Objects with identity and behavior that change over time: a meter, a customer account, a service |
record (class) |
Reference type; value equality | Immutable data passed around: a reading, a message, a result returned from a query or API |
struct |
Value type; copied on assignment | Small values that act like numbers: a temperature, a position, a date |
readonly record struct |
Value type; value equality |
Small immutable values where you also want
with and a readable printout
|
enum |
Value type (a named integer) | A fixed, closed list of options: statuses, priorities |
When in doubt, start with a class or a record. Microsoft's design guidelines advise against a struct unless the type represents a single value, is small (under 16 bytes), is immutable, and will not have to be boxed often; large structs make every assignment and method call copy a lot of data. The generic object-oriented concepts apply to all of these types: records and classes can implement interfaces, and record classes can inherit (see Inheritance, Interfaces and Polymorphism).