Extension Methods and Extension Members in C#

Why This Matters When AI Can Just Write the Code

An AI assistant can generate an extension method for you in seconds — a one-liner with this in front of the first parameter, done. So why does the underlying concept deserve real understanding rather than just trusting the generated snippet? Because extension methods are one of the easiest C# features to use incorrectly while still having it compile and appear to work, and the failure modes are exactly the kind that only show up once real usage patterns emerge — not in the small demo an AI tool shows you.

Here’s a concrete version of the problem: extension methods can never actually override or replace behaviour on the type they extend — they can only add the appearance of new members. If an AI tool generates an extension method with the same name as a real instance method that gets added to the target type later (by you, by a library update, or because you misjudged which member already existed), the real instance method silently wins every single time, with zero warning, zero error, and no visible sign in your code that anything changed. You need to already understand the resolution rules covered in this post — that instance members always beat extension methods, silently — to even suspect this is happening when a call stops behaving the way you expect after an unrelated update.

There’s a second, more subtle reason this topic rewards real understanding: knowing when an extension method or extension member is the right tool — versus when it’s papering over a design problem you should actually be solving with an interface, a wrapper type, or a genuine change to a type you do own — is a judgement call, not a syntax question. An AI tool asked to “add a helper to this type” will readily generate an extension method whether or not that’s actually the right long-term choice, because generating a working answer is different from generating the right answer for your codebase’s shape. Being able to read a codebase full of extension methods (your own, a teammate’s, or a library’s) and understand exactly what’s really happening — a static method dressed up to look like an instance member, nothing more — is a reading-comprehension skill that pays off every time you touch a real project, regardless of who wrote the extension in the first place.

Why This Post Exists

Sometimes you need a type to have a method, property, or operator that it doesn’t currently have — and you either can’t modify the type’s source code (it belongs to the .NET framework, or a third-party library), don’t want to modify it (adding an inheritance relationship or a wrapper just for one helper feels heavy-handed), or structurally can’t modify it in the way you’d like (you can’t retroactively add an interface implementation to a sealed class you don’t own). C# solves this with extension methods, a feature that’s existed since C# 3.0, and — as of C# 14 — a considerably more powerful evolution of the same idea called extension members, which finally allow properties, static members, and operators to be added the same way methods always could.

By the end of this post you should be able to explain precisely what an extension method actually compiles down to and why that explains every rule governing how they behave; write both the classic (this-parameter) syntax and the modern C# 14 extension block syntax; correctly predict member resolution when an extension and a real instance member share a name, and when two unrelated extensions collide with each other; understand how extension methods interact with generics, interfaces, value types, and null; recognise LINQ as the single most consequential real-world application of this feature; and know exactly which new capabilities C# 14’s extension blocks unlock and which fundamental limitation — no genuine new fields — still applies to both syntaxes, and always will.

The Core Idea: Extension Methods Are a Compiler Illusion

Here is the single fact that explains almost everything else in this post, so it’s worth establishing first, very precisely: an extension method is an ordinary static method, and the compiler is simply allowed to call it using instance-method syntax as a special case. Nothing about the target type changes at all — not its fields, not its actual member list, not anything visible via reflection on the type itself. The “extension” only exists from the caller’s point of view, as a trick the compiler performs while translating your source code into the method calls that actually get compiled.

Here’s the classic syntax, which has worked since C# 3.0:

public static class StringExtensions
{
public static int WordCount(this string str) =>
str.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}

Two things make this an extension method rather than an ordinary static helper: it lives in a static class (a hard requirement — extension methods cannot be declared anywhere else, and the class itself cannot be generic or nested), and its first parameter has the this modifier, which tells the compiler “this parameter is the thing being extended; let people call this method as if it belonged to that type.”

string sentence = "The quick brown fox";
int count = sentence.WordCount(); // looks like an instance method call

This line looks exactly like calling a genuine instance method — but the compiler is silently rewriting it, behind the scenes, into an ordinary static method call:

int count = StringExtensions.WordCount(sentence); // what actually gets compiled

Both forms are available to you as the caller — you can write either one, and they mean exactly the same thing. sentence.WordCount() and StringExtensions.WordCount(sentence) compile to identical IL (the intermediate code the .NET runtime actually executes) — the dot-syntax version is purely a convenience the compiler offers you, with zero runtime difference between the two forms. This is worth sitting with, because nearly every rule and limitation covered in the rest of this post follows directly from this one fact: you are not actually adding a member to the type. You are writing a static method, and asking the compiler to let you call it using nicer syntax.

A direct consequence: string doesn’t actually gain a WordCount member

Because nothing about string itself changed, reflection-based code, other languages targeting the CLR without the same extension-method sugar, and anything that inspects string’s actual member list will never see WordCount there at all — it doesn’t exist on string. It exists only as a static method that C# is willing to let you call with instance syntax, and only when the extension method’s containing namespace is in scope via a using directive:

using MyProject.StringExtensions; // required — without this, sentence.WordCount() won't compile at all

If you don’t using the namespace containing the extension method, sentence.WordCount() simply fails to compile — the method genuinely isn’t visible, because as far as the language is concerned outside that using scope, it doesn’t exist as a callable member of string at all.

A second direct consequence: no access to private members

Because an extension method is genuinely just a static method living outside the target type entirely, it only ever has access to the target type’s public (or otherwise externally visible) members — exactly the same access any other unrelated piece of code would have. It cannot reach into private or protected fields the way a real instance method defined inside the class could.

class BankAccount
{
private decimal _balance;
public decimal Balance => _balance;
}

public static class BankAccountExtensions
{
public static void AddInterest(this BankAccount account, decimal rate)
{
// account._balance += ...; // ILLEGAL — _balance is private, and this is just an outside static method
}
}

This is a genuine, and often clarifying, limitation: it means extension methods can only ever build on top of a type’s existing public surface — they can combine, reformat, or compute from what’s already exposed, but they can never reach in and manipulate private internal state the way a genuine member of the class could. If you find yourself wanting an “extension” that needs private access, that’s a strong signal you actually need a real member on the type itself, not an extension.

Extension methods and null: a real behavioural difference from instance methods

Because an extension method call is secretly just a static method call, it follows the rules of an ordinary static call — including a rule that’s genuinely different from how real instance methods behave: you can call an extension method on a null reference without an exception being thrown, as long as the extension method itself is written to handle that gracefully.

public static class StringExtensions
{
public static bool IsNullOrEmpty(this string? str) => string.IsNullOrEmpty(str);
}

string? name = null;
bool result = name.IsNullOrEmpty(); // does NOT throw! prints/evaluates to true

This looks alarming at first — calling a method on null normally throws a NullReferenceException immediately, as covered in any discussion of null handling. But remember: name.IsNullOrEmpty() isn’t really calling a method on name at all in the way a genuine instance method call would; it’s calling StringExtensions.IsNullOrEmpty(name), passing name as an ordinary argument. Passing null as an argument to a static method is completely unremarkable and doesn’t throw anything by itself — whether an exception happens at all depends entirely on what the method’s body actually does with that argument. This is precisely why string.IsNullOrEmpty(str) (a real static method in the .NET standard library, not even an extension) has always been safely callable with null — and it’s a useful, idiomatic pattern: writing null-safe extension methods that let callers skip an explicit null check beforehand, as long as you clearly document and rely on the extension itself handling null correctly rather than assuming its caller already ruled that out.

Member Resolution: Real Members Always Win, Silently

Because an extension method is only ever a fallback the compiler reaches for when it can’t find a genuine matching instance member, there’s a strict, important priority order: if a type already has an instance member with a matching name and signature, that real member is always called instead of any extension method with the same name — with no warning, no ambiguity error, nothing. The extension method simply never gets a chance to run.

class Greeter
{
public string Greet() => "Hello from the real method!";
}

public static class GreeterExtensions
{
public static string Greet(this Greeter g) => "Hello from the extension!";
}

var g = new Greeter();
Console.WriteLine(g.Greet()); // "Hello from the real method!" — the extension is completely ignored

This is the exact scenario flagged in the introduction to this post: if Greeter didn’t originally have a Greet() method, and you wrote the extension expecting it to be called, then a later change that adds a real Greet() method to Greeter — even one with a different implementation, added by someone who’s never heard of your extension — silently and permanently shadows your extension method for every caller, forever, with no compiler error anywhere to flag that anything changed. This is a genuinely realistic bug in evolving codebases and library upgrades, and it’s a direct, unavoidable consequence of what an extension method fundamentally is: a fallback, never a true member, always losing to the real thing.

What happens when two different extensions collide

The “silent, no-error” behaviour above is specific to a real instance member competing with an extension — a real member always wins outright, and the compiler doesn’t even consider it a conflict worth mentioning. The situation is different when two separate extension methods, from two different static classes, both apply to the same type with the same name and signature, and both are in scope via using directives at the same time. Here, the compiler generally does flag the situation, because there’s no real member to automatically defer to:

namespace LibraryA { public static class Ext { public static string Describe(this int i) => "From A"; } }
namespace LibraryB { public static class Ext { public static string Describe(this int i) => "From B"; } }

using LibraryA;
using LibraryB;

int x = 5;
// x.Describe(); // Compile ERROR — ambiguous call between LibraryA.Ext.Describe and LibraryB.Ext.Describe

When this happens, you resolve the ambiguity by falling back to the fully-qualified static method call syntax — calling LibraryA.Ext.Describe(x) directly bypasses the ambiguity entirely, since you’re no longer asking the compiler to search for a matching extension; you’re naming the exact static method you want. This is precisely why extension members still need to live inside a named static class even under the modern C# 14 syntax, as we’ll in more detail later — that container name is your escape hatch out of exactly this kind of collision.

There’s one more wrinkle worth knowing: if the two competing extensions are visible at different levels of scope — for example, one brought in by a using at the top of your file, and another available because it’s declared in the same namespace your code is already in, with no using needed — C# does have a preference order (closer, more specific scopes win over using-imported ones). But when both candidates are equally “close” (as in the two-using-directives example above), the result is a genuine compile-time ambiguity error, not a silent pick of one over the other — which is a meaningfully different, safer outcome than the silent shadowing that happens when a real instance member is involved.

Generic Extension Methods

Extension methods can be generic, and this is, in practice, one of their most powerful and common uses — it’s exactly how a huge portion of the .NET standard library’s most useful helpers (LINQ chief among them) are able to work across virtually any collection type at once, rather than needing a separate hand-written version for every possible element type.

public static class EnumerableExtensions
{
public static T? SecondOrDefault<T>(this IEnumerable<T> source)
{
using var enumerator = source.GetEnumerator();
if (enumerator.MoveNext() && enumerator.MoveNext())
return enumerator.Current;
return default;
}
}

List<int> numbers = [10, 20, 30];
int second = numbers.SecondOrDefault(); // 20 — works for List<int>, and equally for any IEnumerable<T>
string[] words = ["a", "b", "c"];
string? secondWord = words.SecondOrDefault(); // "b" — the exact same method, now working on strings instead

Notice that the caller never has to specify explicitly — the compiler infers it from the actual type of numbers/words being passed as the receiver, exactly the same type inference that happens for any other generic method call. You can also constrain the type parameter exactly as you would on any generic method, which is useful when your extension genuinely needs a capability the type parameter must guarantee:

public static class ComparableExtensions
{
public static T Clamp<T>(this T value, T min, T max) where T : IComparable<T>
{
if (value.CompareTo(min) < 0) return min;
if (value.CompareTo(max) > 0) return max;
return value;
}
}

int clamped = 150.Clamp(0, 100); // 100 — works because int implements IComparable<int>

This pattern — a generic extension constrained to an interface — is an extremely common and idiomatic way to add a single, reusable piece of behaviour across every type that satisfies some capability, without needing to touch any of those types individually.

Extension Methods on Interfaces

You can write an extension method whose receiver type is an interface rather than a concrete class or struct — and this turns out to be one of the single most important applications of the entire feature, because it lets you add functionality that automatically becomes available to every type that implements that interface, all at once, without touching any of them.

interface IShape { double Area(); }

public static class ShapeExtensions
{
public static bool IsLargerThan(this IShape shape, IShape other) => shape.Area() > other.Area();
}

class Circle : IShape { public double Radius; public double Area() => Math.PI * Radius * Radius; }
class Square : IShape { public double Side; public double Area() => Side * Side; }

var c = new Circle { Radius = 2 };
var s = new Square { Side = 3 };
Console.WriteLine(c.IsLargerThan(s)); // works on Circle, Square, or any future IShape implementer, automatically

IsLargerThan was written once, against the interface, and immediately works for Circle, Square, and any type anyone writes in the future that implements IShape — including types that don’t exist yet at the moment this extension was written. This is a genuinely powerful multiplier: extending an interface effectively extends every current and future implementer of that interface simultaneously, which is a much larger reach than extending one specific concrete class.

A subtlety: which members are visible depends on the static type of the expression

Because member resolution (including extension method resolution) in C# happens at compile time based on an expression’s declared type — not its actual runtime type — an extension written for a concrete class won’t be found if you’re holding a reference to that object through a less specific interface or base type, unless the extension itself was also written against that broader type (or the object’s actual compile-time type at the call site).

public static class CircleExtensions
{
public static double Diameter(this Circle c) => c.Radius * 2;
}

IShape shape = new Circle { Radius = 2 };
// shape.Diameter(); // Compile ERROR — Diameter() only extends Circle, but 'shape' is statically typed as IShape

Even though the object really is a Circle at runtime, shape.Diameter() fails to compile, because the compiler only ever considers the extensions available for the expression’s declared type (IShape here), never the object’s actual runtime type. This is a direct, if easy-to-forget, consequence of extension resolution being a purely compile-time, static-typing-based mechanism — there’s no runtime lookup happening at all, unlike genuine virtual method dispatch.

Extension Methods and Value Types: Copies, ref, and in

Extension methods work perfectly well on struct/record struct receivers too, but it’s worth understanding precisely what gets passed, because it directly affects both correctness and performance.

By default, just like an ordinary method parameter, a value-type receiver is passed by value — meaning the extension method receives an independent copy of the struct, and any mutation performed inside the extension method has no effect on the original value the caller passed in:

struct Counter { public int Value; }

public static class CounterExtensions
{
public static void Increment(this Counter c) => c.Value++; // mutates a COPY, not the caller's original
}

var counter = new Counter { Value = 0 };
counter.Increment();
Console.WriteLine(counter.Value); // still 0 — the extension mutated its own local copy, not 'counter'

This is exactly the same “value types copy on pass” behaviour that applies to any ordinary method call — the extension-method call syntax doesn’t change that underlying rule at all, which is easy to forget given how much the dot-syntax makes it look like you’re operating on the original instance.

If you genuinely need the extension to mutate the caller’s original struct, you can mark the receiver parameter ref, exactly as you would for any ordinary method:

public static class CounterExtensions
{
public static void Increment(this ref Counter c) => c.Value++; // now mutates the CALLER's actual struct
}

var counter = new Counter { Value = 0 };
counter.Increment();
Console.WriteLine(counter.Value); // 1 — the extension mutated the real, original struct this time

Conversely, if the struct being extended is large and you want to avoid the performance cost of copying it on every call, but you don’t need to mutate it, marking the receiver in (or, in the modern extension block syntax, using an in receiver) passes the struct by reference for efficiency while still preventing the extension from modifying it — giving you the performance benefit of ref without giving up the safety of read-only access.

What C# 14 Changes: From “Methods Only” to “Extension Everything”

For its entire history prior to C# 14, the extension method feature had a real, sharp limitation: it only worked for methods. You could make something look like sentence.WordCount(), but you could never make something look like a genuine property (sentence.WordCount, no parentheses), a static member on the type itself (string.SomeHelper), or an operator (p1 + p2 for some type p1/p2 you don’t own). Workarounds existed — writing GetWordCount() instead of a true property, for instance — but they always looked and felt like methods pretending to be something else, because that’s exactly what they were.

C# 14 (shipped with .NET 10, November 2025) introduces a genuinely new syntax — the extension block — that removes this limitation entirely, while keeping every classic this-parameter extension method you’ve already written fully working, unchanged, forever. The two syntaxes coexist deliberately; you never need to migrate existing code, and you can freely mix both styles within the same static class.

Here’s the same WordCount example, rewritten using the new extension block syntax:

public static class StringExtensions
{
extension(string str)
{
public int WordCount() =>
str.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
}

The extension(string str) block declares, once, that everything inside it extends string, with str available as the “receiver” — the instance being extended — throughout every member in the block, without needing to repeat a this string str parameter on each individual method the way the classic syntax requires. This alone is a real ergonomic improvement once you’re defining several related members for the same type, but the much bigger unlock is what kinds of members you’re now allowed to put inside that block.

Extension Properties

The clearest, most immediately useful addition in C# 14 is genuine extension properties — something with no parentheses, accessed exactly like a real property, computed on demand:

public static class StringExtensions
{
extension(string str)
{
public bool IsEmpty => string.IsNullOrEmpty(str);
public int WordCount => str.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
}

string title = "Hello World";
Console.WriteLine(title.IsEmpty); // False
Console.WriteLine(title.WordCount); // 2 — accessed as a property, no parentheses at all

This matters more than it might first appear: before C# 14, the only way to expose something as a property-feeling piece of information via an extension was to write a method and simply accept that it would always need parentheses (str.WordCount()), which is a real, if small, readability cost for anything that’s conceptually a computed attribute of the value rather than an action being performed. IsEmpty, WordCount, and similar “describe a characteristic of this value” concepts read far more naturally as properties than as zero-argument methods, and now they finally can be.

Under the hood, this is still ultimately compiled down to a method (a property, extension or not, is always a getter/setter method pair at the IL level) — the underlying “it’s really just a static method with special call syntax” reality hasn’t changed at all. What’s changed is that the compiler now offers property-style call syntax as an option, not just method-style syntax, for extensions. Extension properties can also declare a setter, not just a getter — as long as the extension block has some way to actually apply the change, which in practice usually means the receiver type has a real, settable property or field of its own that the extension property’s setter delegates to underneath.

Static Extension Members

Before C# 14, every extension member — regardless of the syntax — always extended an instance of a type: you needed an actual string value in hand to call .WordCount() on. There was no way to add something that looked like a static member on the type itself, the way string.Empty or Guid.NewGuid() are static members you call on the type, not on an instance of it. C# 14 removes this restriction with static extension members, declared inside an extension block whose receiver has no parameter name (just the bare type):

public static class GuidExtensions
{
extension(Guid) // note: no parameter name — this block declares STATIC members on Guid itself
{
public static Guid Empty2 => Guid.Empty;

public static Guid CreateDeterministic(string input)
{
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(input));
return new Guid(hash.AsSpan(0, 16));
}
}
}

Guid deterministic = Guid.CreateDeterministic("some-stable-key"); // called on the TYPE, not an instance

Notice extension(Guid) here, rather than extension(Guid someGuid) — omitting the parameter name signals that this block declares members that appear directly on the type itself, not on instances of it. This is genuinely new ground for extensions: you can now make a sealed, third-party type appear to have its own static factory methods or constant-like properties, without ever touching its source, and without needing any instance of the type to access them.

A single static class can freely mix an instance-style extension block and a static-style extension block for the same target type, side by side, letting you organise both kinds of extension together:

public static class StringExtensions
{
extension(string str) // instance members
{
public bool IsEmpty => string.IsNullOrEmpty(str);
}

extension(string) // static members — note: no parameter name
{
public static string Repeat(string pattern, int count) =>
string.Concat(Enumerable.Repeat(pattern, count));
}
}

You can also declare a generic extension block, with type parameters and constraints living on the extension(…) declaration itself rather than repeated on every member inside it — a direct generalisation of the generic extension methods, now available for properties and static members too, not just ordinary methods.

Extension Operators

The next major new capability is the ability to define operators as extension members — something that was flatly impossible before C# 14, because operator overloads have always had to be declared as static members of one of the types directly involved in the operation, and you can’t add a static member to a type you don’t own using the classic this-parameter syntax at all.

public static class PointExtensions
{
extension(Point p)
{
public static Point operator +(Point a, Point b) => new Point(a.X + b.X, a.Y + b.Y);
}
}

var p1 = new Point(1, 2);
var p2 = new Point(3, 4);
var sum = p1 + p2; // Point(4, 6) — the + operator now works on a type you didn't write and can't modify

This is a meaningful capability unlock: previously, if you wanted + to work between two instances of a Point-like type from a library you don’t control, your only real option was to define your own wrapper type with its own + operator, and convert back and forth — a real amount of ceremony for what conceptually is a small addition. Extension operators let you add this kind of natural, domain-appropriate syntax directly to types you don’t own, the same way extension methods have always let you add natural-feeling method calls to types you don’t own. Just as with a normal operator overload, at least one of the operator’s parameters generally has to match the receiver type being extended, for the same reason ordinary operator overloading requires this: without that requirement, the compiler would have no principled way to decide which type “owns” the operator.

What’s Still Off-Limits: No Extension Fields, Ever

With properties, static members, and operators all newly available, it’s natural to wonder whether extension fields are coming too — real, honest-to-goodness storage slots added to an existing type. The answer is no, and it’s worth understanding why, because the reason is structural, not a temporary gap the language team just hasn’t gotten to yet.

Recall the foundational fact this entire post rests on: an extension member is, underneath everything, a static method (or a property, which is itself a getter/setter method pair). It doesn’t change the target type’s actual memory layout in any way — the type’s real, physical fields are exactly what they always were, unaffected by any extension you write. A genuine field requires the type itself to set aside actual storage for it at the moment each instance is created — and an extension block, being nothing but syntax sugar around static methods added after the type already exists and already has its layout fixed, has no mechanism to reach back in time and add storage to every existing and future instance of that type. This isn’t a missing feature — it’s a direct, unavoidable consequence of what “extending” a type without modifying its source can possibly mean.

public static class StringExtensions
{
extension(string str)
{
// public int CallCount; // ILLEGAL — extension blocks cannot declare fields, in any C# version
}
}

If you need genuine, persistent, per-instance storage attached to a type you don’t own, an extension member cannot provide it — you need a different tool entirely, such as a ConditionalWeakTable (which associates extra data with an object’s lifetime without modifying the object itself) or a wrapper type that holds both the original object and your additional state. It’s worth being explicit about this limitation precisely because it’s easy to assume, once you’ve seen properties and static members added in C# 14, that fields might follow in some future version — multiple official sources are explicit that this is not planned, because the limitation is structural, not a matter of not having gotten around to it yet.

Alongside fields, C# 14’s extension blocks also don’t support events, nested types, or constructors — extension blocks focus specifically on methods, properties (including indexer-style get/set), and operators.

Disambiguation: Why the Containing Static Class Still Matters

One detail worth knowing, especially once you’re organising a larger codebase: even though the new extension(…) block syntax no longer requires you to repeat the receiver type on every single member, you still need to wrap your extension blocks in an ordinary named static class, exactly as classic extension methods always required:

public static class StringExtensions // this name still matters!
{
extension(string str)
{
public bool IsEmpty => string.IsNullOrEmpty(str);
}
}

The reason this container class still matters, even though extension blocks feel almost like a language-level “reopen this type” mechanism, is disambiguation. If two different libraries each define an extension member with the same name for the same target type — a genuinely realistic scenario once extension members become widely used — the only way to resolve the resulting ambiguity in your calling code is to refer to one specific extension explicitly by its containing static class name (much like you’d disambiguate between two same-named classes in different namespaces). If extension members could exist with no named container at all, this escape hatch simply wouldn’t exist, and a naming collision between two unrelated libraries would become unresolvable rather than merely inconvenient.

LINQ: The Single Most Consequential Real-World Use of Extension Methods

It’s worth pausing on a concrete, large-scale example, because it’s easy to underestimate just how much of C#’s everyday feel actually rests on this one feature: LINQ (Language Integrated Query) — .Where(…), .Select(…), .OrderBy(…), .FirstOrDefault(…), and dozens of similar methods you use constantly — is, almost entirely, just a large, carefully designed set of generic extension methods on IEnumerable.

List numbers = [1, 2, 3, 4, 5, 6];
var evens = numbers.Where(n => n % 2 == 0).Select(n => n * 10);

Where and Select are not real members of List, or of IEnumerable itself — they’re extension methods, defined in System.Linq.Enumerable: they’re generic (working across any element type T), and they extend an interface (IEnumerable), which is exactly why .Where(…) works identically whether you call it on a List, an array, a HashSet, or any other type that implements IEnumerable — including custom collection types you write yourself, which get every LINQ method automatically, for free, the instant they implement that one interface.

This is the single best real-world illustration of why interface-based extension methods are so powerful: the entire LINQ library was written once, against IEnumerable, and it now works — with zero additional code from anyone — on every collection type that has ever implemented that interface, including types written years after LINQ itself first shipped. It’s also a useful lens for revisiting a previous core claim: every single LINQ call you write is secretly a static method call from Enumerable, dressed up with instance syntax, exactly like the small examples earlier in this post — LINQ isn’t a special case the language treats differently; it’s simply the most famous and most heavily used example of the exact mechanism this entire post has been explaining from the very first section.

When to Reach for Extension Members — and When Not To

Extension methods and extension members are the right tool specifically when you want to add behaviour to a type you don’t own (a .NET built-in type, a third-party library type) or when you want to add a natural-feeling helper to a type you do own, but where the helper doesn’t conceptually belong as a core responsibility of the type itself and would clutter its primary definition. Good, common uses include: LINQ-style query helpers, as just covered; formatting/validation/computed-shorthand helpers on built-in types (someString.IsValidEmail(), someDateTime.IsWeekend); and adding ergonomic syntax (properties, operators) to third-party domain types you use heavily but can’t modify.

They’re the wrong tool when what you actually need is genuine shared state (extension members can never hold fields), when the “extension” is really trying to override or replace behaviour a type already has (which we’ve previously showed silently doesn’t work the way you’d expect), or when you find yourself writing dozens of extensions that would be far better expressed as an interface implemented by a real wrapper type you control — extension members are a targeted convenience for adding syntax, not a general substitute for proper object-oriented design when you actually do have the ability to design the type relationships involved.

Summary

An extension method is, underneath its convenient dot-syntax, nothing more than an ordinary static method — the target type itself never actually changes, gains no new member in its real definition, has no access to the type’s private members, and can even be called safely on a null receiver, because it’s really just an argument being passed to a static method. This single fact explains every rule in this post: extension methods only work when their namespace is in scope, a genuine instance member with a matching name always silently wins over an extension with the same name (while two competing extensions instead produce a compile-time ambiguity error), and resolution is based purely on an expression’s compile-time type, never its runtime type. Extension methods can be generic and can extend interfaces rather than concrete types — a combination that, taken to its logical conclusion, is exactly how LINQ works: a library of generic extension methods on IEnumerable that automatically applies to every collection type that has ever implemented that one interface. Value-type receivers are passed by value by default (so mutations inside an extension are silently lost unless the receiver is marked ref), which is a frequent source of confusion for anyone expecting dot-syntax to always mean “operating on the real object.” C# 14 introduces extension blocks — extension(Type receiver) { … } — which keep every classic extension method working unchanged while adding extension properties, static extension members (via a receiver block with no parameter name), and extension operators, none of which were possible before. What remains permanently off the table, in both the classic and modern syntax, is genuine extension fields, because an extension member never changes a type’s actual memory layout — a structural limitation, not a temporary one — and the containing static class still matters under the new syntax specifically as a disambiguation mechanism when two unrelated extensions collide. Used well, extension members let you add natural, readable syntax to types you don’t own or don’t want to clutter; used carelessly, they can silently do nothing at all once a real member with the same name appears, or silently mutate a throwaway copy instead of the value you meant to change — both of which are exactly the kind of bug that only becomes visible once you understand precisely what an extension member really is.

Interfaces, Inheritance, and Composition — How C# Types Share Behaviour

Why This Matters When AI Can Just Write the Code

An AI assistant can generate an interface, a base class, or a composed object graph in seconds — so why does understanding the underlying design tradeoffs still matter? Because generating code that compiles is not the same as generating code that will still be healthy to work with a year from now, and the choices this post covers — interface vs. base class, inheritance vs. composition — are exactly the kind of decision an AI tool cannot reliably make correctly on your behalf, because making it well requires knowing things about your system’s future that simply aren’t in the prompt.

Consider a concrete case: an AI assistant asked to “add logging to these employee classes” will very often reach for a shared base class, because it’s the shortest path to something that compiles, and inheritance-heavy examples dominate a huge amount of the training material such tools learn from. It has no way of knowing that your Employee hierarchy is about to need three more cross-cutting concerns — auditing, permissions, notifications — that don’t nest cleanly into a single chain of base classes. That’s a fact about your project’s trajectory, not something derivable from the code that exists today. The fragile base class problem this post walks through in detail doesn’t show up in the first version of the code at all — it shows up eighteen months later, when someone (quite possibly an AI assistant, asked to “optimise this base class method”) makes a locally reasonable change that silently breaks three derived classes it never saw and was never told about. Preventing that requires a human who understands why tight coupling to a base class’s implementation is risky, at the moment the original design decision gets made — not after the bug report arrives.

There’s also a much more immediate, mechanical trap this post covers: boxing. An AI tool asked to “store some shapes in a list and print their areas” might hand you a List<IShape> full of struct-based shapes without ever mentioning that every single element just got silently boxed onto the heap, or that mutating one of them through the interface reference won’t actually touch your original value. The code runs. It even produces correct output in a small demo. The performance cost and the mutation bug only show up later, under real load, or when someone tries to update a value and can’t figure out why the change isn’t sticking — and at that point you need to already understand what an interface-typed variable actually is under the hood to have any chance of diagnosing it.

The through line across all of this: AI tools are excellent at producing code that satisfies the request sitting in front of them right now. They are not a substitute for the judgement that decides what to ask for — whether a relationship is genuinely “is-a” and stable, whether a value is about to be boxed, whether a shared base class is going to become a maintenance liability. That judgement is something you bring to the tool; it isn’t something the tool can hand back to you.

Why This Post Exists

This post has two connected goals. The first is to properly introduce interfaces as a concept in their own right — not just “a thing you type after a colon,” but a fundamentally different kind of type from class, struct, record, and record struct, with its own rules and its own sharp edges (boxing, chief among them). The second goal builds directly on the first: once you understand what an interface is and how it differs from inheritance, you’re in a position to properly evaluate one of the oldest and most consequential design debates in object-oriented programming — when to reuse behaviour through inheritance, and when to reuse it through composition instead.

By the end of this post you should be able to explain, structurally, what an interface actually is and why it can be implemented identically by a reference type or a value type; predict exactly where boxing will silently occur when value types meet interfaces; explain the fragile base class problem in your own words, with a concrete example; build the same shared behaviour two different ways — via a base class and via composition — and compare the tradeoffs directly; and make a reasoned, justified choice between inheritance and composition for a specific design situation, rather than defaulting to either one out of habit.

What an Interface Actually Is, Structurally

Every type you might already be familiar with — class, struct, record, record struct — is something that holds data. When you create an instance of any of them, memory gets set aside (on the heap or inline, depending on the kind) to store actual values: an int X, a string Name, whatever fields or properties the type declares.

An interface is fundamentally different: it holds no data of its own, and, historically, contained no implementation at all. An interface doesn’t describe what a thing is made of — it describes what a thing can do. It’s a contract, a promise, a list of members that any implementing type agrees to provide:

interface IShape
{
double Area();
double Perimeter();
}

IShape has no fields, and — in its classic form — no method bodies. You cannot write new IShape(); there is nothing to construct, because an interface isn’t a blueprint for an object, it’s a checklist a type must satisfy. This is the single most important thing to internalise before anything else in this post: a class/struct/record/record struct answers the question “what data does this hold and how is it stored?” An interface answers a completely different question: “what operations can I guarantee this type supports?”

Because of that difference in purpose, an interface can’t stand alone the way the other four kinds can. IShape by itself doesn’t correspond to any actual object in memory — you always need some concrete type (a class, struct, record, or record struct) that implements IShape and supplies the actual fields and method bodies before you have something you can construct and use:

class Circle : IShape
{
public double Radius;
public double Area() => Math.PI * Radius * Radius;
public double Perimeter() => 2 * Math.PI * Radius;
}

IShape shape = new Circle { Radius = 3 }; // shape is typed as IShape, but the actual object is a Circle

Notice the variable shape is declared with type IShape, but there’s no such thing as “a bare IShape object” sitting in memory. What’s actually there is a Circle, and shape is simply a way of looking at that Circle through a narrower lens — one that only exposes the members IShape promises (Area() and Perimeter()), hiding everything else Circle might also have (like the public Radius field itself, which isn’t accessible through an IShape-typed reference without casting back to Circle).

An Interface Is Orthogonal to class/struct/record/record struct — Not a Fifth Option

This is the point students most often get subtly wrong: it’s tempting to mentally file “interface” as a fifth item in the same list as class/struct/record/record struct, as if you’re choosing between five options. That’s not the right mental model. Every one of those four type kinds can implement any number of interfaces, completely independently of which kind it is. The choice of “class vs struct vs record vs record struct” and the choice of “which interfaces does this type implement” are two entirely separate decisions that don’t constrain each other.

To make this concrete, here’s the exact same interface implemented by all four kinds:

interface IShape
{
    double Area();
}

class CircleClass : IShape
{
    public double Radius;
    public double Area() => Math.PI * Radius * Radius;
}

struct CircleStruct : IShape
{
    public double Radius;
    public double Area() => Math.PI * Radius * Radius;
}

record CircleRecord(double Radius) : IShape
{
    public double Area() => Math.PI * Radius * Radius;
}

record struct CircleRecordStruct(double Radius) : IShape
{
    public double Area() => Math.PI * Radius * Radius;
}

All four of these compile without complaint, and all four satisfy IShape identically as far as anything consuming the interface is concerned:

void PrintArea(IShape shape) => Console.WriteLine(shape.Area());

PrintArea(new CircleClass { Radius = 2 });        // works
PrintArea(new CircleStruct { Radius = 2 });        // works
PrintArea(new CircleRecord(2));                    // works
PrintArea(new CircleRecordStruct(2));               // works

PrintArea doesn’t know or care whether it was handed a reference type or a value type, a plain type or a record — it only knows it received something that has an Area() method, because that’s all IShape promises. This is the entire point of an interface: it lets you write code against a capability, without committing to a specific underlying representation.

You now have three genuinely independent axes describing any given type: reference vs. value, plain vs. record, and implements-this-interface vs. doesn’t. None of the three constrains the others. You could just as easily write a class that does not implement IShape, or a struct that implements three different interfaces at once, or a record that implements none — all of that is unrelated to whether the type is a class, struct, record, or record struct.

Interface Variables and Boxing

Assigning a value type to an interface-typed variable creates the exact same boxing situation as assigning it to object, and it’s worth walking through explicitly, because it’s a very easy trap to fall into without noticing.

struct CircleStruct : IShape
{
public double Radius;
public double Area() => Math.PI * Radius * Radius;
}

var s = new CircleStruct { Radius = 2 }; // lives on the stack, no allocation yet
IShape shape = s; // BOXING happens right here!

The moment you assign a value-type instance to a variable of interface type, the runtime has to box it — that is, copy the entire value onto the heap and hand you back a reference to that heap copy, because an interface-typed variable is, under the hood, a reference, and a value type has no “address” of its own to hand out. This happens completely silently; there’s no special syntax that flags it, no warning, nothing in the code that visually screams “allocation happening here.” The line IShape shape = s; looks just as innocuous as any other assignment, but it triggers a heap allocation that the equivalent line for a class-based IShape implementation never would.

This has a very concrete, easy-to-demonstrate consequence: boxing creates a copy, so mutating the boxed copy through the interface reference does not affect the original struct:

interface ICounter { void Increment(); }

struct Counter : ICounter
{
public int Value;
public void Increment() => Value++;
}

var c = new Counter { Value = 0 };
ICounter boxed = c; // boxing: a separate copy is made on the heap
boxed.Increment(); // mutates the BOXED COPY
Console.WriteLine(c.Value); // still 0 — the original struct never changed

This is a classic, very real source of confusing bugs: code that looks like it’s calling a mutating method on your object is actually calling it on a disposable heap copy that gets thrown away the moment the interface reference goes out of scope. class and record never have this problem, because they were reference types to begin with — assigning one to an interface-typed variable is just copying a reference to the same object that already existed, with no boxing and no hidden copy.

The practical lesson: if you’re storing value types (struct/record struct) in a collection typed by interface — a List<IShape> full of struct-based shapes, for instance — every single element got boxed on the way in, and you’re paying a heap allocation per element, exactly the cost you were probably trying to avoid by choosing a value type in the first place.

Multiple Interface Implementation vs. Single Base Class

Here’s a structural fact about interfaces that becomes the seed of the entire second half of this post, so it’s worth planting clearly now: a type can implement as many interfaces as you like, but a class or record can only inherit from one base class.

interface IFlyable { void Fly(); }
interface ISwimmable { void Swim(); }
interface IWalkable { void Walk(); }

class Duck : IFlyable, ISwimmable, IWalkable
{
public void Fly() => Console.WriteLine("Flying");
public void Swim() => Console.WriteLine("Swimming");
public void Walk() => Console.WriteLine("Walking");
}

Duck satisfies three completely independent contracts at once, with no conflict. Contrast this with inheritance, where C# only allows a single base class:

class Animal { }
// class Duck : Animal, SomeOtherBaseClass { } // ILLEGAL — cannot inherit from two classes

This asymmetry exists because inheritance from a base class brings along actual implementation and state — fields, method bodies, constructors — and the language (like most mainstream OOP languages) sidesteps the substantial complexity of “what happens when two base classes both define a field called X, or both implement a method the same way” by simply disallowing multiple base classes altogether. Interfaces sidestep this problem because, classically, they carry no state and no implementation to conflict — a type promising to satisfy IFlyable and a type promising to satisfy ISwimmable can’t possibly clash, because neither promise brings any actual code or data along with it.

This is exactly why interfaces are the tool of choice when a type needs to participate in several unrelated capabilities at once — a Duck being flyable, swimmable, and walkable are three orthogonal facts about it, and forcing that into a single inheritance chain (Duck : FlyingAnimal, but then where does swimming come from?) would quickly become awkward or impossible. Keep this example in mind — Part Two builds directly on this observation to explain why composing behaviour out of small, focused interfaces is often a better design than building deep inheritance trees.

Default Interface Members — Interfaces Aren’t Entirely Implementation-Free Anymore

Everything above described the classic, “pure contract” interface: no fields, no method bodies, only signatures. That was the whole story before C# 8, and it’s still the right mental model for the overwhelming majority of interfaces you’ll write. But since C# 8, interfaces have been allowed to provide a default implementation for a member, which a class doesn’t have to override unless it wants to:

interface IGreeter
{
string Name { get; }
void Greet() => Console.WriteLine($"Hello, {Name}!"); // default implementation
}

class Robot : IGreeter
{
public string Name => "R2D2";
// Robot doesn't implement Greet() at all — it inherits IGreeter's default body
}

IGreeter g = new Robot();
g.Greet(); // prints "Hello, R2D2!" using the interface's own default implementation

This is a genuinely useful escape hatch for library authors: it lets you add a new member to a widely-used interface after the fact, without breaking every existing type that already implements it, because any existing implementer that doesn’t define the new member simply falls back to the interface’s default body instead of failing to compile.

It’s important to be precise about what this does and doesn’t change, though. A default interface member is still only reachable through the interface-typed reference, not through the concrete type directly:

Robot r = new Robot();
// r.Greet(); // Compile error! Robot itself doesn't declare Greet() — only IGreeter does
IGreeter g = r;
g.Greet(); // Works — accessed through the interface

And an interface still cannot hold instance state — no fields — even with default members, so it remains fundamentally different from a base class, which can hold both fields and default behaviour. The takeaway isn’t “interfaces are basically abstract classes now” — they’re still not — but rather: don’t be surprised in real-world code when you see an interface member with a body, and know that it means “this is the fallback behaviour, which any implementer is free to override,” not “this interface secretly has state or a constructor.”

Two Different Ways to Reuse Code

Part One established two facts that this half of the post now builds directly on top of: struct and record struct cannot inherit from anything at all, while class and record can inherit from exactly one base type; and any of the four type kinds can implement as many interfaces as it likes, with no such “only one” restriction. Those two facts, side by side, raise an obvious design question: when you want two or more types to share behaviour, when should you reach for inheritance, and when should you reach for composition instead?

This isn’t just a C#-specific syntax question — it’s one of the oldest and most consequential design debates in object-oriented programming, usually summarised as the principle “favour composition over inheritance.” Suppose you’re building a small set of employee types, and every employee needs the ability to log a message somewhere. There are two structurally different ways to give every employee type that ability.

Inheritance says: create a common base class that contains the shared behaviour, and have every type that needs it inherit from that base class. This is often described as an “is-a” relationship — a Manager is a Employee, so it makes sense for Manager to inherit from Employee and receive everything Employee already knows how to do.

class Employee
{
public string Name;
public void Log(string message) => Console.WriteLine($"[{Name}] {message}");
}

class Manager : Employee
{
public void ApproveTimeOff(Employee e) => Log($"Approved time off for {e.Name}");
}

Manager gets Log for free, simply by inheriting from Employee. This works, and for a simple, genuinely hierarchical relationship like this one, it’s a completely reasonable choice.

Composition says something different: instead of inheriting the behaviour, hold a reference to an object that provides it, and delegate to that object when you need the behaviour. This is often described as a “has-a” relationship — an Employee has a logger, rather than is a logger.

interface ILogger
{
void Log(string message);
}

class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}

class Employee
{
private readonly ILogger _logger;
public string Name;

public Employee(ILogger logger) => _logger = logger;

public void LogActivity(string message) => _logger.Log($"[{Name}] {message}");
}

Here, Employee doesn’t inherit logging behaviour from anywhere — it holds an ILogger and delegates to it. Nothing about Employee’s type hierarchy needs to change to support logging; it just needs a reference to something that knows how to log.

Both examples achieve the same visible result (an employee that can log a message), but they achieve it through fundamentally different mechanisms, and those mechanisms have very different consequences as the codebase grows — which is the subject of the rest of this post.

Inheritance’s Real Cost: Tight Coupling and the Fragile Base Class Problem

Inheritance isn’t wrong — it’s genuinely the right tool in some situations, which we’ll cover later— but it comes with a cost that’s easy to underestimate the first time you use it, and expensive to discover later: a derived class is tightly coupled to its base class’s implementation, not just its public contract. When you inherit from a class, you don’t just gain access to its public members — you become dependent on how those members are implemented internally, in ways that aren’t always obvious from reading the derived class alone.

This is widely known as the fragile base class problem: a seemingly safe, reasonable change to a base class can silently break derived classes that depend on it, even though nothing about the base class’s public interface changed. Here’s a concrete demonstration:

class Collection
{
private List<int> _items = new();

public virtual void Add(int item)
{
_items.Add(item);
Console.WriteLine("Item added");
}

public void AddRange(IEnumerable<int> items)
{
foreach (var item in items)
Add(item); // calls the virtual Add — including any override!
}
}

class LoggingCollection : Collection
{
public int AddCount = 0;

public override void Add(int item)
{
base.Add(item);
AddCount++;
}
}

At first glance, LoggingCollection looks correct: every call to Add increments AddCount, so AddCount should always equal the number of items added. But watch what happens with AddRange:

var c = new LoggingCollection();
c.AddRange(new[] { 1, 2, 3 });
Console.WriteLine(c.AddCount); // 3 — correct, because AddRange calls the overridden Add for each item

This particular example happens to work, but only because Collection.AddRange was deliberately written to call the virtual Add method, so overrides get picked up. That’s precisely the danger: LoggingCollection’s correctness silently depends on an internal implementation detail of Collection — specifically, that AddRange routes through Add rather than, say, appending directly to a private list for efficiency. If a future maintainer of Collection “optimises” AddRange like this:

public void AddRange(IEnumerable<int> items)
{
_items.AddRange(items); // "optimisation": skip the virtual call overhead
Console.WriteLine($"{items.Count()} items added");
}

LoggingCollection.AddCount silently stops being accurate, and nothing about LoggingCollection’s own code changed at all. The bug was introduced entirely inside the base class, in a method LoggingCollection never even overrode. This is the fragile base class problem in miniature: the base class’s author has to think about every possible derived class’s assumptions before making almost any internal change, forever — because derived classes are coupled not just to the base class’s public signatures, but to its internal call patterns, which are usually invisible from outside.

The deeper this hierarchy grows — EmployeeSalariedEmployeeManagerSeniorManager, each layer adding assumptions about the layers below — the harder it becomes to change anything near the top without a ripple of subtle breakage further down, and the harder it becomes for someone reading SeniorManager in isolation to know which behaviours actually come from where. Composition sidesteps this problem structurally: when Employee merely holds an ILogger, nothing about ILogger’s internal implementation can leak into Employee’s correctness in this way, because the only thing Employee depends on is the interface’s public contract.

Composition in Practice: Building Behaviour Out of Small, Focused Interfaces

The Duck example from Part One — implementing IFlyable, ISwimmable, and IWalkable all at once — was already an example of composition-friendly thinking, just without a supporting object behind each interface yet. Let’s complete that picture. Rather than a Duck inheriting flying/swimming/walking behaviour from some deep, awkward hierarchy, it can instead hold small objects that each know how to do one thing, and delegate to them:

interface IMovementStrategy { void Move(); }

class FlyingMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Flapping wings and flying");
}

class SwimmingMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Paddling through water");
}

class Duck
{
private readonly IMovementStrategy _airMovement;
private readonly IMovementStrategy _waterMovement;

public Duck(IMovementStrategy airMovement, IMovementStrategy waterMovement)
{
_airMovement = airMovement;
_waterMovement = waterMovement;
}

public void Fly() => _airMovement.Move();
public void Swim() => _waterMovement.Move();
}

var duck = new Duck(new FlyingMovement(), new SwimmingMovement());
duck.Fly(); // "Flapping wings and flying"
duck.Swim(); // "Paddling through water"

Notice what this buys you that inheritance couldn’t easily offer: the specific way a Duck flies is now a swappable, independently testable piece, completely decoupled from what a Duck fundamentally is. If you later need a RoboDuck that swims the same way but “flies” via a jetpack, you don’t need to redesign an inheritance hierarchy at all — you just construct it with a different IMovementStrategy:

class JetpackMovement : IMovementStrategy
{
public void Move() => Console.WriteLine("Rocketing through the air");
}

var roboDuck = new Duck(new JetpackMovement(), new SwimmingMovement());
roboDuck.Fly(); // "Rocketing through the air" — no change to the Duck class itself

This is precisely why composition is often described as more flexible than inheritance: behaviour becomes something you plug in through a constructor (or property), rather than something baked permanently into a type’s position in a fixed hierarchy at compile time. It also makes unit testing considerably easier — in a test, you can hand Duck a fake IMovementStrategy that does nothing but record that it was called, without needing to construct any of the real flying/swimming logic at all, something that’s often awkward to do cleanly when behaviour comes from an inherited base class instead.

Composition and Value Types: Not a Workaround, but the Only Option

Above, we established that struct and record struct cannot inherit from anything at all — this isn’t a missing feature the language forgot to add, it’s a direct, deliberate consequence of what a value type is. Because of this, composition isn’t an optional alternative for value types the way it is for classes — it’s the only mechanism value types have for sharing or reusing behaviour beyond what interfaces alone provide.

readonly record struct Point(double X, double Y)
{
public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}

readonly record struct Circle(Point Center, double Radius)
{
public double Area() => Math.PI * Radius * Radius;
public bool Contains(Point p) => Center.DistanceTo(p) <= Radius;
}

Circle doesn’t — and structurally can’t — inherit from Point to get distance calculations. Instead, it composes a Point as one of its own fields, and reuses Point’s DistanceTo method by calling it on that held instance. This is a completely natural, idiomatic way to build up richer value types out of smaller ones, and it’s worth explicitly noticing that this is exactly the same composition principle from the Duck example — just applied to small, immutable value objects instead of behaviour-swapping strategy classes. Small, focused, composable value objects like Point, Money, and Address are exactly the kind of thing record/record struct are designed to make easy to build and combine.

So When Does Inheritance Actually Make Sense?

None of this means inheritance is a mistake to avoid entirely — it means inheritance should be a deliberate choice made when its specific strengths genuinely apply, rather than a reflexive first instinct. Inheritance tends to be the right tool when:

  • The relationship is genuinely “is-a,” and is unlikely to need to change at runtime. A Circle really is a kind of Shape in a way that’s true for the entire lifetime of the object — it doesn’t stop being a circle and become a rectangle later. Contrast this with “a Duck can currently fly using wings, but that specific mechanism might reasonably need to be swapped” — that’s a “has-a, and might change” relationship, which favours composition.
  • You want to take advantage of polymorphism through a shared base type, especially when you have a closed, well-understood set of variants and want the compiler to help you handle them exhaustively — for example, an abstract record Shape with sealed record Circle/Rectangle subclasses, paired with a switch expression that handles each case.
  • The shared behaviour is small, stable, and unlikely to change underneath derived types. The fragile base class problem gets worse the more a base class’s internals change over its lifetime; if a base class is simple and essentially “done,” the risk that inheritance’s tight coupling will bite you is much lower.
  • You’re modelling a genuine hierarchy with more than one level of specialisation that the domain itself supports — for example, a UI framework’s ControlButtonBaseButton hierarchy reflects real, stable, structural relationships in how those types behave, not just convenient code reuse.

The practical decision process, then, isn’t “composition good, inheritance bad” — it’s closer to: default to composition and interfaces for sharing behaviour, especially across value types or unrelated capabilities, and reach for inheritance specifically when you have a genuine, stable “is-a” relationship where polymorphism is the actual goal, not just a convenient way to avoid retyping a method.

Summary

An interface is not a fifth alternative to class/struct/record/record struct — it’s an orthogonal contract that any of the four can promise to fulfil, completely independent of whether that type is a reference type or a value type, or a plain type or a record. All four type kinds can implement the same interface identically from the caller’s perspective. The place this orthogonality has a real, measurable cost is boxing: assigning a struct/record struct to an interface-typed variable silently allocates a heap copy, and mutating through that boxed copy never affects the original value — a class/record never has this problem, because it was already a reference type. Interfaces also don’t share inheritance’s “only one” restriction — a type can implement as many interfaces as it needs, which is precisely what makes them the natural tool for composing several independent capabilities onto one type. And since C# 8, interfaces can carry default implementations for their members, which softens but doesn’t erase the core distinction: an interface still holds no state of its own, and remains fundamentally a promise about behaviour, not a container for data.

Building on that: inheritance and composition are two different mechanisms for sharing behaviour across types, and they carry very different long-term costs. Inheritance expresses an “is-a” relationship and gives you polymorphism through a shared base type, but it comes with tight coupling to the base class’s implementation, not just its public contract — the fragile base class problem means a seemingly safe change deep inside a base class can silently break derived classes that never touched the changed code. Composition expresses a “has-a” relationship: instead of inheriting behaviour, a type holds a reference to an object (usually through an interface) and delegates to it, which keeps types decoupled from each other’s internals and makes behaviour swappable at runtime rather than fixed at compile time. This isn’t a purely stylistic preference for class/record — for struct/record struct, which structurally cannot inherit at all, composition is the only mechanism available for building richer value types out of smaller ones. The practical guidance is to default to composition and interfaces, and reach for inheritance specifically when you have a genuine, stable “is-a” relationship where polymorphism through a shared base type is the actual goal — not simply a shortcut to avoid writing a method twice.

Null, not null, forgiving null (or maybe not)

Why This Matters When AI Can Just Write the Code

An AI assistant can generate null checks in seconds — so why does understanding null handling matter if you can just ask for it? Because null-related bugs are almost never caught by generating code; they’re caught by reading code and recognising a gap in its reasoning, and that’s a skill no amount of code generation substitutes for.

Here’s a concrete version of the problem: an AI tool asked to fix a compiler warning about a possibly-null reference will very often reach for the null-forgiving operator (!) — it makes the warning disappear, which looks like success. But ! doesn’t add a safety check; it removes one, telling the compiler “trust me, this is never null” without verifying that’s actually true. If the assumption is wrong, the code now fails at runtime with a NullReferenceException, in a place that used to at least warn you at compile time. Whether that ! was a reasonable judgement call or a bug waiting to happen depends entirely on context the tool doesn’t reliably have — and if you don’t understand what ! actually does, you have no way to tell the difference between “the AI just fixed a warning” and “the AI just hid a real bug” by reading the diff.

There’s a second reason this topic specifically rewards genuine understanding: null handling sits at the intersection of the type system and runtime behaviour in a way that’s easy to get subtly wrong even when every individual line looks reasonable. Knowing the difference between a compile-time-only annotation and an actual runtime guarantee — which this post covers in detail — is exactly the kind of judgement that determines whether you trust a piece of generated code or double-check it. That judgement is yours to develop; no tool can hand it to you pre-installed.

Every C# codebase you’ll ever work in has to deal with the possibility that a value is missing, not yet computed, not yet loaded, or intentionally absent. null is the language’s way of representing “nothing is here” — and it is, by a wide margin, one of the most common sources of runtime crashes in real software. This post covers what null actually is at the language and memory level, every mainstream tool C# gives you for checking and guarding against it, the crucial and easily-confused distinction between nullable value types and nullable reference types, the attributes that let library authors describe null behaviour precisely, and the null-forgiving operator — a tool that’s easy to misuse precisely because it looks like a safety feature.

By the end of this post you should be able to explain, precisely, what null represents in memory for a reference type and why the same idea doesn’t apply to a value type; correctly predict, for any given line of code, whether a nullability rule is enforced at compile time, at runtime, or not enforced at all; choose the right null-checking idiom for a given situation and justify the choice; read and apply the nullability attributes ([NotNullWhen], [MaybeNull], and similar) that describe how a method’s nullability behaves; and recognise when the null-forgiving operator is a defensible judgement call versus a bug waiting to happen.

What Null Actually Is, at the Memory Level

To understand null precisely, it helps to think about what a variable actually contains for each kind of type.

For a reference type (a class, for instance), a variable never directly contains the object’s data. It contains a reference — conceptually, an address — pointing to where the actual object lives on the heap. null is simply the special, reserved value meaning “this reference doesn’t point anywhere at all.” No heap object is required to exist for a variable to hold null; the variable is, in a very literal sense, empty.

string name = null;   // name holds no address at all — there is no string object to find
Console.WriteLine(name.Length); // throws NullReferenceException

name.Length fails here because evaluating it requires the runtime to follow the reference stored in name to find an actual string object and read its Length property off of it. But name isn’t pointing at anything, so there’s no object to follow the reference to, and the runtime throws NullReferenceException rather than silently doing nothing or returning a default value. This single mechanism — attempting to dereference an empty reference — is the direct cause of the most common runtime exception in C# (and in every other mainstream language that adopted the same idea from reference-type null).

For a value type (like int or a struct), the story is completely different: the variable is the data, stored directly and inline, with no indirection at all. An int variable is quite literally four bytes holding a number — there’s no separate “int object” living elsewhere that the variable points to. Because there’s no reference involved, there’s nothing that could be “empty” — the variable always holds actual bits representing some value.

int x = null; // compile error CS0037 — there is no concept of "no address" for a value type

This is why the compiler rejects this outright, at compile time, rather than allowing it and failing later — the very idea of null doesn’t apply to how a plain value type is represented in memory. This distinction is the foundation for everything else in this post: Nullable<T> and nullable reference types are two entirely different mechanisms, built to solve the same conceptual problem (“how do I represent ‘no value’ for this type?”) for two categories of type that are represented in memory in fundamentally different ways.

It’s worth a brief historical note here, because it explains why the language ended up with two different mechanisms instead of one unified one: the inventor of the null reference, Tony Hoare, has publicly called it his “billion-dollar mistake” — a feature that seemed convenient to add to a type system in 1965, but which he estimates has since caused decades of subsequent bugs, crashes, and security vulnerabilities across virtually every mainstream language that copied the idea. Nullable<T> (added in C# 2.0) and nullable reference types (added in C# 8.0, over a decade later) represent two separate, successive attempts to make that original mistake less costly — one for value types, one for reference types — without breaking the enormous amount of existing code built on the assumption that any reference could always be null.

Nullable Value Types: Nullable<T> in Full

Since a plain value type can never be null, C# provides a real, concrete wrapper type — Nullable<T> — that adds an explicit “is a value actually present or not” flag alongside the underlying data. You’ll almost always see it written using the shorthand T?:

int? age = null;          // shorthand for Nullable<int> age = null;
Nullable<int> age2 = null; // exactly equivalent, spelled out in full

Nullable<T> is itself a struct, meaning int? remains, fundamentally, a value type — not a disguised reference type. Its actual definition is close to this simplified version:

public struct Nullable<T> where T : struct
{
public bool HasValue { get; }
public T Value { get; } // throws InvalidOperationException if HasValue is false
public T GetValueOrDefault();
public T GetValueOrDefault(T defaultValue);
}

So when you write int? age = null;, nothing about “emptiness” or “missing references” is happening — you’re creating an ordinary struct value whose HasValue field happens to be false. The variable is never “pointing at nothing”; it directly contains a small struct value that represents absence as one of its normal, well-defined states, the same way a bool can represent true or false.

int? age = null;
Console.WriteLine(age.HasValue);           // False
// Console.WriteLine(age.Value);           // throws InvalidOperationException — no value to return
Console.WriteLine(age.GetValueOrDefault()); // 0 — the default value of int, used safely instead of throwing

age = 30;
Console.WriteLine(age.HasValue); // True
Console.WriteLine(age.Value);    // 30

GetValueOrDefault() is often safer to reach for than .Value directly, precisely because it can never throw — it simply falls back to default(T) (or an explicit fallback you supply) when there’s no value present, rather than requiring you to check HasValue first every single time.

Lifted operators

C# does something convenient behind the scenes called “operator lifting”: arithmetic and comparison operators automatically work on Nullable<T> values, propagating null through the calculation, without you having to unwrap .Value manually:

int? a = 5;
int? b = null;

int? sum = a + b; // null — if either operand is null, the result is null
Console.WriteLine(sum.HasValue); // False

bool isGreater = a > b; // False — comparisons involving a null operand are always false, never throw

Notice the last line carefully: a > b doesn’t throw and doesn’t propagate null the way arithmetic does — it evaluates to false, because a comparison against “no value” is defined as never being true. This is a common source of subtle bugs: a > b being false does not mean a <= b is true when either side might be null — both comparisons can independently be false if one side is null, which surprises people expecting comparisons to be perfect opposites of each other the way they are for non-nullable numbers.

Boxing behaviour — a special case worth knowing

Normally, boxing a value type wraps it in an object on the heap, as covered in earlier value-type discussions. Nullable<T> has a special, deliberately designed exception to ordinary boxing rules: boxing a null-valued Nullable<T> produces an actual null reference, not a boxed struct with HasValue == false sitting on the heap.

int? x = null;
object boxed = x;
Console.WriteLine(boxed == null); // True — boxing a null Nullable<T> yields an actual null reference, not a boxed struct

This special-cased behaviour exists specifically so that boxed nullable value types behave the way programmers intuitively expect when compared against null or checked with is null — without it, a great deal of ordinary-looking code checking someObject == null would behave surprisingly for nullable value types passed around as object.

Nullable Reference Types (NRT): A Compile-Time Annotation, Not a Runtime Guarantee

Historically, every reference type in C# could always be null — there was no way to say “this particular string variable should never be null” and have the compiler enforce it. Since C# 8, when a project enables the nullable reference types feature (also called “the nullable context”), you can annotate reference type usages with ? to declare intent:

string name = "Alice";   // intended to always have a value — a "non-nullable" reference type
string? nickname = null; // intended to possibly have no value — a "nullable" reference type

Here is the single most important fact in this entire post, and the one students most reliably get wrong the first time: this ? is purely a compile-time annotation that the compiler uses to generate warnings. It changes absolutely nothing about what happens at runtime. Unlike Nullable<T>, which is a real wrapper struct that genuinely changes the shape of the underlying data, nullable reference type annotations are erased completely by the time your code runs. A string? and a string are the exact same type — System.String — at runtime, compiled to identical IL, with identical behaviour. The only difference between them exists in the compiler’s static analysis while you’re writing the code, not in anything the CLR (Common Language Runtime) knows or checks while your program is actually running.

You can prove this to yourself directly:

string name = null; // triggers compiler WARNING CS8600 — but this still compiles and runs!
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime, exactly as before NRT existed

Nothing stops this from compiling. The nullable reference types feature is, by design, a warning system layered on top of the language — not an enforced runtime guarantee the way Nullable<T> is. This is a deliberate, pragmatic tradeoff: C# has decades of existing code where every reference type could be null, and retroactively making non-nullable the enforced runtime default would have broken an enormous amount of that code the moment anyone upgraded their compiler. Instead, the compiler performs static flow analysis — tracking, as precisely as it can, which variables might be null at each point in your code — and surfaces a warning wherever it believes you might be about to dereference something that could be null, without physically preventing you from doing it anyway.

The nullable context: enabling and controlling NRT

Whether nullable reference type checking is even active is itself controlled, either project-wide (typically via <Nullable>enable</Nullable> in your .csproj file) or file-by-file, or even line-by-line, with a compiler directive:

#nullable enable
string? maybeNull = null; // annotations are meaningful here — warnings apply

#nullable disable
string alsoNull = null;   // in a disabled context, no warning at all — this is the pre-C#-8 world

#nullable restore          // returns to whatever the surrounding project-level setting was

Code written before nullable reference types existed, or code in a project that hasn’t opted in, is said to be in an “oblivious” context — the compiler doesn’t apply nullable warnings to it at all, and every reference type behaves exactly as it always did (implicitly nullable, no annotations, no warnings). This matters practically: if you’re calling into an older library that hasn’t been annotated for nullability, the compiler generally can’t warn you about null risks flowing out of that library’s methods, even in your own fully nullable-enabled code, because the library itself never declared its intentions.

Generic type parameters and NRT

Nullability annotations interact with generics in a way that’s worth flagging explicitly, since it trips people up:

class Box<T>
{
public T Value; // is this nullable or not? It depends entirely on what T ends up being!
}

Box<string> b1 = new Box<string> { Value = null };
Box<int> b2 = new Box<int> { Value = null };

The compiler has to reason about nullability generically here, because T might end up being a reference type or a value type depending on how Box<T> gets used — this is a genuinely more advanced corner of the feature, and library authors writing generic code often need extra annotations (like T? combined with constraints, or the [MaybeNull]/[AllowNull] attributes covered later) to precisely describe nullability that depends on the type parameter.

Advisory, not enforced

This all has a very practical consequence worth internalising: nullable reference type warnings are advisory, not enforced, by default. If your team ignores compiler warnings, or your build pipeline doesn’t treat warnings as errors, string? provides essentially no actual protection at all — it becomes documentation of intent that nothing is actually forcing anyone to respect. Many professional teams configure their build to treat nullable-related warnings as build-breaking errors (<WarningsAsErrors>Nullable</WarningsAsErrors> or similar) specifically to close this gap and make the annotations behave more like a genuine guarantee — which is worth knowing exists as an option even though it isn’t the default.

Checking for Null

C# provides several distinct ways to check whether something is null, and they aren’t fully interchangeable — the differences matter, sometimes a great deal.

== null vs. is null

if (name == null) { ... }
if (name is null) { ... }

Both usually produce the same result, but is null is the generally preferred idiom, for a specific and important reason: == is an operator, and operators can be overloaded by the type being compared. If the type of name overloads == with custom logic — records do exactly this automatically, since they generate their own == for value equality — then name == null invokes that custom operator rather than a guaranteed, unambiguous null check. A poorly written custom == overload could, in principle, behave unexpectedly when one side is null (for example, throwing instead of returning false, if the override doesn’t defensively handle a null argument). is null, by contrast, is a pattern match evaluated directly by the compiler using the runtime’s built-in notion of “this reference is empty” — it cannot be overridden, intercepted, or fooled by any type’s custom operator, so it always performs a true, unambiguous null check no matter what type you’re checking, which is exactly the guarantee you want from a null check.

The null-conditional operator: ?.

Rather than writing a nested chain of null checks by hand, ?. lets you safely navigate through a chain of references that might be null at any step, automatically short-circuiting to null the moment it encounters one:

// Without null-conditional:
string city = null;
if (customer != null && customer.Address != null)
city = customer.Address.City;

// With null-conditional — equivalent, far more concise:
string city = customer?.Address?.City;

If customer is null, the entire expression short-circuits immediately and evaluates to null — it never even attempts to access .Address, avoiding the exception a plain customer.Address.City would throw. You can also use it to conditionally invoke a method or event, calling it only if the reference isn’t null and otherwise doing nothing at all:

onCompleted?.Invoke(); // calls Invoke() only if onCompleted isn't null; silently does nothing otherwise

?. also combines with indexers and method calls in a chain, short-circuiting the moment any link is null:

int? firstItemLength = list?[0]?.Length; // safely handles list being null, or list[0] being null

Null-coalescing operators: ?? and ??=

?? lets you supply a fallback value to use when the left-hand side turns out to be null, and it chains naturally with ?.:\

string city = customer?.Address?.City ?? "Unknown";
// If the whole chain above evaluates to null at any point, "Unknown" is used instead

??= is a compound assignment version: it assigns the right-hand side only if the variable currently holds null, leaving it completely unchanged otherwise — useful for lazy initialisation and simple caching patterns:

string? cachedResult = null;
cachedResult ??= ComputeExpensiveResult(); // only computes and assigns if cachedResult was null
cachedResult ??= ComputeExpensiveResult(); // this second call never even executes ComputeExpensiveResult again

Pattern matching against null

Modern C# lets you use is/is not directly as readable null checks, and switch expressions can match null explicitly as one of several patterns, putting the null case on equal footing with every other case being matched rather than treating it as a special pre-check:

if (name is not null)
Console.WriteLine(name.Length);

string Describe(object? value) => value switch
{
null => "nothing here",
int n when n < 0 => $"a negative int: {n}",
int n => $"an int: {n}",
string s => $"a string: {s}",
_ => "something else"
};

The Try-pattern as an alternative to nullable returns

A very common and idiomatic way to sidestep null-checking altogether for lookups that might fail is the “Try” pattern, seen throughout the .NET standard library (Dictionary<TKey,TValue>.TryGetValue, int.TryParse, and many others):

Dictionary<string, int> ages = new() { ["Alice"] = 30 };

if (ages.TryGetValue("Bob", out int age))
Console.WriteLine(age);
else
Console.WriteLine("Not found");

Rather than returning a nullable value type and forcing every caller to check for null (or, worse, returning a sentinel value like -1 that’s easy to forget to check for), the method returns a bool indicating success and delivers the actual value through an out parameter only when it succeeded. This is often considered a cleaner API design than nullable returns for exactly this kind of “might not find anything” scenario, because the success/failure check and the value retrieval happen in a single, hard-to-misuse expression — you cannot accidentally use age without having gone through the success check first, since it isn’t definitely assigned otherwise.

The Null-Forgiving Operator: !

The null-forgiving operator is a single exclamation mark placed after an expression, and it exists specifically to interact with the compile-time warning system:

string? maybeName = GetNameOrNull();
string name = maybeName!; // "trust me, compiler — this won't actually be null"

Here is the fact students most often assume incorrectly, so it’s worth stating as plainly as possible: the ! operator does absolutely nothing at runtime. It performs no check, adds no safety, and throws no exception if the value genuinely turns out to be null. Its only effect is telling the compiler’s nullable-warning analysis “stop warning me about this specific expression — I know something you don’t.” If you’re wrong, and the value actually is null, the code compiles cleanly with no warning at all, and then throws a NullReferenceException at runtime exactly as it would have without the ! — except now there was no compiler warning to have caught the mistake ahead of time, which arguably makes the situation worse than not using NRT at all, since it creates false confidence.

string? maybeName = null;
string name = maybeName!; // compiles with zero warnings
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime — the ! changed nothing here

This makes ! a genuinely double-edged tool, and it’s worth being explicit about when each side applies.

It’s a legitimate, reasonable choice when you have information the compiler’s flow analysis genuinely can’t see. A common example is right after a manual null check performed inside a separate helper method, which the compiler generally can’t trace through automatically:

void EnsureNotNull(string? value)
{
if (value is null) throw new ArgumentException();
}

string? input = GetInput();
EnsureNotNull(input);
string result = input!; // input can't actually be null here, but the compiler can't see that through the helper call

(Later we’ll cover [NotNull] and related attributes, which let you tell the compiler about exactly this kind of pattern without needing ! at all — a more precise, more scalable solution than sprinkling ! throughout your codebase.)

It’s a code smell — often papering over a real bug — when it’s used reflexively just to silence a warning without actually verifying the assumption behind it. If you find yourself typing ! purely because the compiler is complaining and you want the complaint to stop, that’s exactly the situation where you’re most likely to be quietly disabling a warning that was correctly flagging a genuine risk. A good habit: every time you write !, be able to state in one sentence why you’re certain the value can’t be null at that specific point — if you can’t articulate that reason, it’s a strong signal to add an actual check instead of suppressing the warning.

! used on more than just simple variables

The null-forgiving operator can be applied to any expression, not just a bare variable, which is worth knowing so you can recognise it in the wild:

var firstAdult = people.FirstOrDefault(p => p.Age >= 18)!; // asserts the result won't be null, suppressing the warning

This particular example is a common trap: FirstOrDefault returns null when no element matches the predicate, so asserting the result away with ! here is only safe if you’ve independently verified, through other logic, that a match is guaranteed to exist — otherwise you’ve silenced a warning that was correctly describing a real possibility.

Nullability Attributes: Precisely Describing a Method’s Null Behavior

Sometimes a method’s nullability can’t be fully captured by a simple ? or its absence — the actual behavior depends on what happens when the method runs, not just its static signature. C# provides a small set of attributes, primarily used by library authors, that let you describe this precisely so the compiler’s flow analysis can follow along correctly at every call site, without callers needing ! at all.

[NotNullWhen(bool)] — describes an out parameter (or return value) that is guaranteed non-null specifically when the method returns a particular bool value. This is exactly how TryGetValue-style methods are annotated in the standard library:

bool TryGetName(int id, [NotNullWhen(true)] out string? name)
{
if (id == 1) { name = "Alice"; return true; }
name = null;
return false;
}

if (TryGetName(1, out string? result))
Console.WriteLine(result.Length); // no warning here — the compiler trusts the attribute's promise

Without this attribute, the compiler would still warn about result.Length, since result’s declared type is string?. The attribute tells the compiler, precisely, “whenever this method returns true, treat that out parameter as non-null from that point forward” — letting the compiler’s flow analysis stay accurate without any ! needed anywhere at the call site.

[MaybeNull] — the opposite kind of promise: marks something whose declared type looks non-nullable, but which might actually return null at runtime anyway (often used for backward compatibility with older, unannotated APIs, or generic code where T might turn out to be a reference type).

[NotNull] — asserts that a parameter or return value will never be null, even though its declared type technically allows it (string?) — commonly used on out/ref parameters that a method always assigns a real value to before returning, regardless of input.

[AllowNull] / [DisallowNull] — used on parameters to describe input nullability independently of a property’s own get/set nullability — for example, a property whose setter accepts null (which then gets normalised to an empty string internally) but whose getter never returns null.

[DoesNotReturn] — marks a method that never returns normally (it always throws), which lets the compiler correctly treat any code after a call to it as unreachable — useful for custom guard-clause helper methods:

[DoesNotReturn]
void ThrowIfInvalid(string? input)
{
if (input is null) throw new ArgumentNullException(nameof(input));
throw new InvalidOperationException(); // this method always throws, one way or another
}

You won’t need most of these day-to-day as an application developer — but recognizing them when you see them in .NET’s own source, or in a well-annotated third-party library, is what lets you understand why the compiler correctly avoids warning you in some cases and correctly does warn you in others, rather than it feeling arbitrary.

Guard Clauses and Trusting the Type System

In real code, you’ll constantly face a judgement call: should you defensively check every argument for null at the top of every method, or trust that the type system (via nullable reference type annotations) has already ruled null out?

A common, concise pattern for explicit, defensive checks at the boundaries of your code — especially public APIs where callers might not respect your nullable annotations, might be calling from an oblivious (pre-NRT) context, or might be passing data that came from outside your program’s control entirely (deserialised JSON, user input, a database row) — is:

public void ProcessOrder(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
// from this point on, customer is guaranteed non-null, with a clear, immediate exception if it wasn't
Console.WriteLine(customer.Name);
}

ArgumentNullException.ThrowIfNull is a concise, standard-library way to fail fast and loudly, with a clear exception message naming exactly which argument was the problem — far more useful for debugging than an anonymous NullReferenceException thrown from somewhere deep inside the method body later on, potentially far removed from where the actual bad value entered your code.

The broader judgement call — defend everywhere vs. trust the type system — doesn’t have one universally correct answer, but a reasonable default is: check defensively at the boundaries of your code (public APIs, deserialised data, anything coming from outside your own program’s control), and trust your non-nullable annotations internally, once you’re confident your codebase takes nullable warnings seriously (ideally with warnings-as-errors enabled). Checking null obsessively on every single private method call, when the type system has already given you strong non-nullable guarantees internally, adds clutter without meaningfully reducing risk — but skipping checks entirely at your public boundaries, where you don’t control what callers pass in, removes a safety net you likely still need.

Required Properties: A Different Tool for “This Must Be Set”

Nullable reference type warnings are about whether a reference might be null. A related but genuinely distinct problem is object initialisation: how do you guarantee a property gets a value at all, without necessarily making it nullable? Since C# 11, the required modifier addresses this directly:

class Customer
{
public required string Name { get; init; }
public string? Nickname { get; init; } // genuinely optional
}

var c = new Customer { Nickname = "Al" }; // compile error! Name is required and wasn't set
var c2 = new Customer { Name = "Alice" }; // fine — Name provided, Nickname left null

This is a compile-time-enforced guarantee that a property must be explicitly set during construction, which is a stronger and more precise tool than simply leaving a property non-nullable and hoping every constructor path sets it — required makes the compiler actively check every object-creation site for a missing assignment, rather than relying on nullable-warning flow analysis to eventually notice a gap, which it might not always catch depending on how the object gets constructed.

null vs. default — A Related but Distinct Idea

It’s worth clearly separating null from a closely related but different concept: default(T), or its shorthand default. default means “the zero-initialised value for type T” — for reference types, that value happens to be null, but for value types, it’s a real, valid value (0 for int, false for bool, all-zero fields for a struct), not an absence of anything.

string s = default; // null — the default for any reference type is null
int i = default; // 0 — a completely valid, ordinary int value, not an absence of a value
Point p = default; // a Point with all fields zeroed out, if Point is a struct — still a real Point

This distinction matters because it’s easy to conflate “no value was provided” with “the default value was provided,” and they aren’t the same thing for value types: a Point that’s default is a perfectly real, usable Point sitting at the origin — it’s not “missing” the way Nullable<Point> being null would be. If you need to distinguish “a real, valid zero value was explicitly provided” from “no value was provided at all” for a value type, default alone cannot do that — you need Nullable<T> (or T?) specifically, because only Nullable<T> carries an explicit HasValue flag separate from the value itself.

Null in Collections: A Design Convention Worth Adopting

One more practical distinction worth internalising, since it comes up constantly in real code: an empty collection and a null collection mean two different things, and conflating them is a common source of bugs and defensive-code clutter.

List<string> tags = new(); // an empty list — "this customer has zero tags," a known, valid state
List<string>? tags2 = null; // no list at all — "we don't know this customer's tags," a fundamentally different state

A very widely adopted convention in professional C# code is: methods that return collections should return an empty collection instead of null, whenever there’s a meaningful “nothing here” case, specifically so every caller can safely foreach over the result without needing a null check first:

// Preferred:
public List<string> GetTags(Customer customer) => customer.Tags ?? new List<string>();

// Avoid, if at all possible:
public List<string>? GetTags(Customer customer) => customer.Tags; // forces every caller to null-check before iterating

This convention doesn’t apply universally — sometimes “we don’t know” genuinely needs to be distinguished from “we know there are zero” — but as a default, it eliminates an enormous number of unnecessary null checks scattered throughout a codebase, and it’s worth adopting deliberately rather than by accident.

Summary

null represents an empty reference — literally, a variable holding no address at all — which is why the concept applies naturally to reference types (which hold an address to data stored elsewhere) but not to plain value types (which hold their data directly, with no address to leave empty). Nullable<T> gives value types a real, runtime opt-in mechanism for representing absence anyway, implemented as an actual struct carrying a genuine HasValue flag, complete with lifted operators and a special boxing rule that makes a null Nullable<T> box to an actual null reference. Nullable reference type annotations (string?) work in a completely different way: they’re a compile-time-only warning system, controlled by the nullable context, with zero effect on runtime behaviour — a string can still be assigned null and still throws a NullReferenceException exactly as before, and the annotation only changes what the compiler warns about while you’re writing the code, not what’s enforced when it runs, unless your build explicitly treats those warnings as errors. For checking null, prefer is null over == null since it can’t be fooled by a type’s overloaded == operator, lean on ?./??/??= and pattern matching for concise handling, and reach for the Try-pattern over nullable returns when a lookup might reasonably fail. The null-forgiving operator ! does nothing at runtime — it only silences a compiler warning — so treat it as a deliberate, justifiable assertion, not a routine fix, and prefer the [NotNullWhen]/[NotNull]-style attributes when you’re the one writing an API whose nullability depends on its behaviour rather than its static signature. Keep default conceptually separate from null (a value type’s default is a real, valid value, not an absence of one), default to returning empty collections rather than null ones wherever “nothing here” is a meaningful, expected state, defend explicitly against null at the boundaries of your code where you don’t control incoming data, and trust your non-nullable annotations internally once your team takes warnings seriously.