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
Copying a struct versus copying a class reference. On the left, p1 and p2 each hold their own data, X 1 and X 9. On the right, c1 and c2 each hold a reference that points to the same single object on the heap, which has X 9. struct: the data is copied p1 X = 1, Y = 2 p2 X = 9, Y = 2 two independent copies class: the reference is copied c1 c2 X = 9 Y = 2 references one object (heap)
Assigning a struct copies the data; assigning a class instance copies only the reference, so both variables reach the same object.

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).