Fundamentals - Part 19

01/24/2026
4 minute read

What a record is (and the two flavors)

  • record (record class) is a reference type with value-based equality.
  • record struct is a value type with value-based equality (and no allocation for locals).
  • Positional records (record Person(string Name, int Age)) synthesize properties, equality, Deconstruct, and ToString.
  • Nominal records (record Person { public string Name { get; init; } }) give you the same semantics but you declare the properties yourself.

Benefits of records

  • Value equality by default: two records with the same data are equal.
  • Nondestructive mutation: with creates a copy with a few changed members.
  • Concise models: less boilerplate for DTOs, value objects, and messages.
  • Good debugging output: ToString prints property names and values.
  • Immutability is encouraged: init-only setters work well with records.

Drawbacks of records

  • Equality can be surprising: records compare by data, not by reference identity.
  • Shallow copying: with copies the top-level members; nested reference types are shared.
  • Versioning risks: adding a property changes equality and ToString, which can be a breaking change.
  • Potential for accidental mutability: if you use set instead of init, you can break invariants and hash collections.

Gotchas to watch for

Gotcha 1: with is shallow

public record Person(string Name, List<string> Tags);

var a = new Person("Ada", new List<string> { "admin" });
var b = a with { Name = "Grace" };

b.Tags.Add("ops");
// a.Tags now also contains "ops"

Use immutable collections (like ImmutableArray<T> or ImmutableList<T>) if you want a deep immutability story.

Gotcha 2: equality includes all instance data

Adding a new property changes Equals, GetHashCode, and ToString.

public record Order(string Id)
{
    public string? CorrelationId { get; init; } // now part of equality
}

That is great when the new member is part of identity, and harmful when it is not. Be deliberate.

Gotcha 3: ToString can leak data

Records print all value members by default. If a record can contain secrets, override ToString or PrintMembers.

Gotcha 4: inheritance has strict equality rules

Two record instances are equal only if they are the same runtime type and all value members are equal. A User record will not equal an AdminUser record even if the data matches.

Gotcha 5: mutation breaks hashing

If a record is used as a dictionary key or hash set element, any mutation of value members after insertion can make it "disappear" from the collection.

Mutable records: impact and guidance

What changes when records are mutable?

  • with becomes less valuable (you can just mutate).
  • Equality and hashing become unstable if value members can change.
  • Reasoning becomes harder because a record looks immutable to many readers.

When mutable records are appropriate

  • Internal types with very short lifetimes (tests, internal DTOs).
  • Types that never appear as dictionary keys or hash set elements.
  • Places where you explicitly want value equality but not immutability.

When mutable records are inappropriate

  • Public API types (callers will assume immutability).
  • Types used as keys in hash-based collections.
  • Types with strong invariants that must always hold.

Primary constructor parameter vs. property

The difference

  • The primary constructor parameter is an input value.
  • The property is the stored value that participates in equality, Deconstruct, and ToString.

In a positional record, the compiler turns each primary constructor parameter into a public property and wires it into all the generated members.

public record Person(string Name);
// Equivalent to:
// public string Name { get; init; }

If you need custom behavior, you can explicitly declare the property and the compiler will use your version.

Customizing a property with a primary constructor

1) Normalize or validate in the init accessor

public record Person(string Name)
{
    private string _name = string.Empty;

    public string Name
    {
        get => _name;
        init => _name = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Name required", nameof(Name))
            : value.Trim();
    }
}

This keeps the positional syntax while enforcing invariants.

2) Add derived properties while keeping the positional ones

public record EmailAddress(string Value)
{
    public string Domain => Value.Split('@')[1];
}

Derived properties are also part of equality, so only add them if that is what you want.

3) Switch to nominal form when the property logic dominates

public record Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; } = "USD";
}

Nominal records are clearer when the primary constructor is no longer the center of the design.

When to choose records (rules of thumb)

  • You want value semantics for a data-centric type.
  • Immutability is the default (use init).
  • You want concise models for DTOs, events, and value objects.
  • You can accept that equality and hashing are based on member values.

If you need identity, complex lifecycle, or polymorphic behavior with mutable state, prefer a class instead.

Final thoughts

  • Records are great for small, immutable data shapes with clear value semantics.
  • with is a convenience, not a deep clone.
  • If you choose mutability, do it knowingly and keep records away from hash-based collections.
An error has occurred. This application may no longer respond until reloaded. Reload x