agentleFS
glennawatsonglennawatson/CSharpAgentSkills/skills/csharp-modern-types/SKILL.md

csharp-modern-types

Skill2 starsChanged 42 days agoMITView on GitHub

Use when declaring or reviewing C# types — choosing between record, record struct, readonly record struct, readonly struct, and class; primary constructor capture pitfalls; required/init members; `with` expressions and shallow copy; record value-equality traps (collections, inheritance, mutable members); sealed defaults; file-scoped types; and the C# 14 `field` keyword.

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Double-capture warning (CS9124): if a parameter is both used to initialize a member *and* captured elsewhere in the body, the compiler …

    line 59
  2. Very specific:

    Use an immutable, value-comparable collection type (ImmutableArray<T>, or compare SequenceEqual explicitly) if you need this to be true.

    line 116
  3. Distinctive:

    Only usable inside the accessors of the property that owns the implicit field.

    line 154
  4. Very specific:

    Check TargetFramework/LangVersion before reaching for either (see csharp-language-versions) — both need net11.0+/C# 15.

    line 158

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Modern types — pick the right shape, avoid the equality traps

C# now has five common shapes for a type. Pick by mutability and value vs. reference semantics, not by habit.

Choosing a shape

ShapeSemanticsMutableUse for
classreference, identity equalityyesservices, entities with identity, anything with behavior/lifetime
record (class)reference, value equalityyes (unless you make it init-only)immutable-by-convention data with structural equality; DTOs, messages
record structvalue type, value equalityyessmall, copyable, immutable-by-convention value data (already gets value equality free)
readonly record structvalue type, value equalitynothe default choice for small immutable value data — no defensive copies needed
readonly structvalue type, identity via members you definenosmall value types where you don't want generated value equality/ToString/with

Rule of thumb: need identity or a lifetime → class. Need structural equality → record. Small (≤ a few words) and copied by value → prefer a struct variant, readonly unless you have a real reason to mutate in place.

// entity with identity — plain class
class Customer
{
    public int Id { get; init; }
    public string Name { get; set; } = "";
}

// immutable value data compared by contents — readonly record struct
readonly record struct Money(decimal Amount, string Currency);

// immutable reference data compared by contents — record
record OrderPlaced(int OrderId, DateTimeOffset When);

Primary constructors — the capture pitfall

A primary constructor parameter on a class/struct (not record, which turns them into properties automatically) is not just constructor plumbing — if you use it anywhere outside the parameter list's own initializers, the compiler captures it as a private field, and that field is mutable, even if you never intended to write to it:

class Counter(int start)
{
    // `start` is captured into a hidden field because it's read here
    public int Next() => start++;   // compiles — the "parameter" is silently mutable
}

There's no way to mark a captured primary-constructor parameter readonly — if you need immutability, assign it to a real readonly field or an init property explicitly:

class Counter(int start)
{
    private readonly int _start = start;   // now genuinely immutable
    public int Value => _start;
}

Double-capture warning (CS9124): if a parameter is both used to initialize a member and captured elsewhere in the body, the compiler warns because you likely meant to reference the member, not silently re-capture the parameter as a second piece of state:

class Dual(int value)
{
    public int Stored { get; } = value;   // initializes a member from the parameter
    public int Get() => value;            // CS9124: also captured directly — probably meant `Stored`
}

Fix by referencing the member (Stored) instead of the parameter once it's been promoted to a field/property.

required and init

init makes a member settable only during object-initializer syntax (or the constructor); required forces callers to set it there. Together they replace constructor-parameter boilerplate for DTOs while keeping immutability after construction:

class Person
{
    public required string Name { get; init; }
    public int Age { get; init; }
}

var p = new Person { Name = "Ann" };   // Age defaults to 0; Name is mandatory — CS9035 if omitted

required members can still be set via a constructor if you mark it with [SetsRequiredMembers] — use that when you have a real constructor that guarantees the invariant, so callers aren't forced into object-initializer syntax.

with expressions — shallow copy only

with on a record/record struct produces a shallow copy: value-type members are copied by value, reference-type members copy the reference, not the referenced object.

record Basket(List<string> Items);

var a = new Basket(["apple"]);
var b = a with { };
b.Items.Add("banana");
// a.Items now also contains "banana" — same List<string> instance

If a member is a mutable collection (or any mutable reference type), with does not give you an independent copy of it. Either make the member itself immutable (ImmutableList<T>, IReadOnlyList<T> backed by a fresh array on each with) or deep-copy it explicitly in a custom constructor.

Record value equality — where it traps you

Records generate member-wise Equals/GetHashCode/== automatically, which is exactly what you want for plain immutable data — and exactly what bites you in three situations:

1. Collections inside records compare by reference, not contents, because List<T>/arrays/Dictionary<,> don't override Equals. Two records holding "the same" list contents are not equal:

record Basket { public List<string> Items { get; init; } = []; }

var b1 = new Basket { Items = ["apple"] };
var b2 = new Basket { Items = ["apple"] };
b1 == b2;   // false — reference equality on the List<string>

Use an immutable, value-comparable collection type (ImmutableArray<T>, or compare SequenceEqual explicitly) if you need this to be true.

2. Inheritance changes what "equal" means. Record equality checks the runtime type first, so a Base and a Derived : Base with identical inherited members are never equal — but two Derived instances compare all members, including ones added down the hierarchy, and a virtual Equals override that isn't updated when you add a derived record can silently stop comparing new members. Prefer a sealed leaf record for anything that must have simple value equality; keep base records abstract.

3. Mutable members break the hash-as-key assumption. A record's GetHashCode reads its current field values — if you mutate a record (via a settable, non-init property) after putting it in a HashSet<T>/using it as a Dictionary<TKey,> key, its hash changes and the collection can no longer find it. Keep records that participate in hashing fully init-only.

sealed defaults

Records are not sealed by default — record Base { } can be inherited unless you say sealed record Base { }. This is different from many expectations and matters both for equality (see above) and for API design: seal a record the moment nothing should derive from it, which is most of the time for data-only records.

Classes are also unsealed by default. If a class isn't designed for inheritance, sealed it — it documents intent and lets the JIT devirtualize calls.

File-scoped types

file class/file record/file struct restrict a type to the file that declares it — useful for helper types (source-generator output, a private implementation detail shared by a couple of top-level functions) that shouldn't leak into the assembly's public surface or collide by name with another file's helper:

file class ParseState
{
    public int Position;
}

The field keyword (C# 14)

Inside a property accessor, field refers to the compiler-synthesized backing field without you having to declare one — useful for adding validation/transformation to an auto-property without the ceremony of a full manual backing field:

class Widget
{
    public string Name
    {
        get;
        set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
    } = "";
}

Only usable inside the accessors of the property that owns the implicit field; if you need to read/write the backing storage from elsewhere in the type, you still need an explicit field.

.NET 11 / C# 15 — union vs. closed hierarchy vs. classic abstract base

Check TargetFramework/LangVersion before reaching for either (see csharp-language-versions) — both need net11.0+/C# 15. Never raise LangVersion or retarget just to use them; on net10.0 and earlier, use the classic abstract/sealed base pattern below instead.

ShapeCase types share a base?Cases addable outside your assembly?Use for
union Pet(Cat, Dog, Bird);No — unrelated types, no inheritance neededNo — fixed set declared inline"exactly one of these otherwise-unrelated shapes" — parse results, API response variants
closed record class GateState; + derived recordsYes — real shared members/behavior via inheritanceNo — compiler-enforced to the declaring assemblyA small, fixed state/variant set that shares behavior and should never grow from outside
abstract record Shape; + sealed derived records (classic, works on any TFM)YesYes, in principle (only convention stops it)Same as closed, but on net10.0/older, or when you do want extensibility
// union — no natural common base, just "one of these"
public union Pet(Cat, Dog, Bird);
public record Cat(string Name);
public record Dog(string Name);
public record Bird(string Name);

// closed hierarchy — shared behavior via inheritance, assembly-sealed case set
public closed record class GateState
{
    public abstract string Label { get; }
}
public sealed record Open : GateState { public override string Label => "open"; }
public sealed record Closed : GateState { public override string Label => "closed"; }

closed rules: implies abstract (can't instantiate the closed type directly); can't combine with sealed/static/abstract on the same declaration; derived types are restricted to the declaring assembly (stronger than sealed's per-type convention — no other assembly can ever add a case); not transitive — an intermediate base in a multi-level hierarchy needs its own closed if you want that subtree closed too. union case types get implicit conversions to the union type, and System.Text.Json can serialize/deserialize a union directly.

Default to the classic abstract/sealed pattern from the table above when the project doesn't target net11.0+ yet, or when the type genuinely needs to be extensible by other assemblies — closed/union are for when a fixed, compiler-enforced case set is exactly what you want. See csharp-pattern-matching for how these interact with switch exhaustiveness.

Checklist

  • Reference/identity type → class; structural-equality data → record; small immutable value data → readonly record struct
  • Primary constructor parameters that must stay immutable are promoted to a readonly field/init property, not left as a captured mutable parameter
  • No CS9124 warnings (parameter both initializing a member and separately captured) left unaddressed
  • with isn't relied on to deep-copy mutable reference members (collections especially)
  • Records holding mutable collections don't rely on ==/hashing over those collections unless using a value-comparable collection type
  • Base records intended as leaves are sealed; hierarchies used for equality are deliberate, not accidental
  • Records/objects used as dictionary keys or in hash sets are init-only, never mutated after insertion
  • File-local helper types use file instead of internal/private sprawl

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.