Polymorphism in C#: the kinds, how virtual and interface dispatch work, and when to use polymorphism versus pattern matching.
Polymorphism means one interface, many forms: code written against a base type or interface works with any implementation, and each object behaves according to its actual type. Run-time (subtype) polymorphism uses virtual/override and interfaces; the call is dispatched through the object's method table (a vtable slot for virtual methods, an interface dispatch stub for interfaces), so shape.Area() runs Circle.Area or Square.Area. Compile-time polymorphism is chosen by the compiler: method overloading, generics (and static abstract interface members for generic maths). Pattern matching on types (switch on shape) is the alternative: good for closed sets of data types and operations added often; virtual methods are better when new types are added often.
Polymorphism means one piece of code works with many types. C# has two broad kinds: compile-time (the compiler picks the implementation — overloading, generics) and run-time (the object's actual type picks the implementation — virtual methods and interfaces). I'd explain both, how virtual dispatch actually works, and where pattern matching fits as an alternative.
Overloading — same name, different parameters; chosen by static types:
public static decimal Gst(decimal amount) => amount * 0.1m;
public static decimal Gst(Invoice invoice) => invoice.Lines.Sum(l => Gst(l.Amount));
Generics — one algorithm, many types, type-checked at compile time:
public static T Max<T>(ReadOnlySpan<T> items) where T : IComparable<T> { ... }
public static T Sum<T>(ReadOnlySpan<T> items) where T : INumber<T> { ... } // generic math (static abstract members)
For value types the JIT generates specialised code per type, so generic polymorphism has zero dispatch cost. Operator overloading is another static form.
Virtual methods / abstract classes:
public abstract class Shape { public abstract double Area(); }
public sealed class Circle(double r) : Shape { public override double Area() => Math.PI * r * r; }
public sealed class Rect(double w, double h) : Shape { public override double Area() => w * h; }
IEnumerable<Shape> shapes = [new Circle(1), new Rect(2, 3)];
var total = shapes.Sum(s => s.Area()); // each call dispatches on the runtime type
Interfaces — unrelated types implementing the same contract (IPaymentGateway implemented by Stripe and Adyen adapters). This is how DI-based applications vary behaviour: the caller depends on the interface, the container provides an implementation.
sealed classes, exact types from new, value types in generics, and with dynamic PGO (default since .NET 8) it inserts guarded devirtualisation ("if it's usually Circle, call Circle.Area directly and inline it").double Area(Shape s) => s switch
{
Circle c => Math.PI * c.R * c.R,
Rect r => r.W * r.H,
_ => throw new UnreachableException()
};
This is the functional approach: behaviour lives in one function over a closed set of types. Trade-off (the "expression problem"):
| Virtual methods | Pattern matching | |
|---|---|---|
| Adding a new type | Easy: new subclass | Hard: update every switch |
| Adding a new operation | Hard: add to every subclass | Easy: new function |
| Best for | Open hierarchies, plug-ins | Closed sets: result types, messages, ASTs |
I use virtual/interface polymorphism for extensibility points and pattern matching for closed domain variants (e.g. PaymentResult subtypes mapped to HTTP responses).
A tax calculation module had a 300-line switch on a Country string scattered across three services; adding New Zealand meant editing all three and one was missed. We introduced ITaxPolicy with implementations per jurisdiction resolved from DI by key (.NET 8 keyed services), so a new country was one class plus a registration. Conversely, our API response mapping used a switch over a sealed OrderResult hierarchy, because the set of outcomes was closed and having the mapping in one place was clearer than virtual ToHttpResult() methods on domain types.
Checks understanding of a core OOP mechanism and the ability to pick between OOP and functional styles.
obj --> [header][MethodTable*][fields]
|
v
MethodTable: [..., parent*, ..., vtable slot 0 (ToString), slot 1 (Equals), slot 2 (GetHashCode), slot 3 (Finalize), slot 4 (Area) ...]
A virtual call s.Area() compiles (x64, roughly) to:
mov rax, [rcx] ; MethodTable
mov rax, [rax + 0x40] ; vtable chunk
call [rax + 0x20] ; slot for Area
The cost is small (a couple of dependent loads + indirect branch, usually well predicted). The bigger cost is lost inlining: an inlined Area might be folded into the loop and vectorised; a virtual one can't.
Interface slots differ per implementing type, so call sites use stubs:
So interface calls at monomorphic sites are about as fast as virtual calls; megamorphic sites are slower. (.NET has been moving some scenarios to "cached interface dispatch" similar to NativeAOT.)
| Situation | Devirtualised? |
|---|---|
Receiver type is sealed |
Yes |
Receiver created with new in the same method |
Yes (exact type known) |
Value type in a generic method with constraints (T : IComparable<T>) |
Yes — specialised code, direct call |
| Reference type in shared generic code | Usually no (shared __Canon code) |
| Dynamic PGO observes one dominant type | Guarded devirtualisation: if (mt == Circle) inline Circle.Area else virtual call |
EqualityComparer<T>.Default |
Special-cased intrinsic |
This is why sealing classes (CA1852) and using generic constraints instead of interface parameters on hot paths have real performance benefits.
In type theory:
T.Zero, T.Parse, operators on T.Virtual calls dispatch on one object (the receiver). To dispatch on two runtime types (e.g. collision between Shape pairs), options are the visitor pattern (two virtual calls), a pattern-matching switch on a tuple (a, b) switch { (Circle, Rect) => ... }, or dynamic (slow, runtime-checked).
The compiler turns type patterns into a sequence of isinst checks (or a decision DAG). For a handful of types it's comparable to virtual dispatch; for many types, ordering matters. Sealed records in a closed hierarchy make the switch both fast and exhaustive-looking (still requires _ until C# gets closed hierarchies / unions).
DOTNET_JitDisasm to see guarded devirtualisation with PGO enabled.Sum<T> with INumber<T> and call it with int and decimal.// Run-time polymorphism via an interface
public interface ICarrier
{
string Code { get; }
decimal Quote(Parcel parcel);
}
public sealed class AusPostCarrier : ICarrier
{
public string Code => "AUSPOST";
public decimal Quote(Parcel p) => 9.95m + p.WeightKg * 1.20m;
}
public sealed class CourierCarrier : ICarrier
{
public string Code => "COURIER";
public decimal Quote(Parcel p) => p.Express ? 25m : 15m;
}
public sealed record Parcel(decimal WeightKg, bool Express);
public sealed class QuoteService(IEnumerable<ICarrier> carriers)
{
public IReadOnlyList<(string Carrier, decimal Price)> QuoteAll(Parcel parcel) =>
carriers.Select(c => (c.Code, c.Quote(parcel))).OrderBy(q => q.Item2).ToList();
}
// Compile-time polymorphism: generics with static abstract members (generic maths)
static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in values) total += v;
return total;
}
int a = Sum([1, 2, 3]); // 6
decimal b = Sum([1.5m, 2.5m]); // 4.0
// External polymorphism: closed hierarchy + pattern matching
public abstract record Shape;
public sealed record Circle(double R) : Shape;
public sealed record Rect(double W, double H) : Shape;
static double Area(Shape s) => s switch
{
Circle c => Math.PI * c.R * c.R,
Rect r => r.W * r.H,
_ => throw new ArgumentOutOfRangeException(nameof(s))
};
What happens: Handle(object o) and Handle(Order o) overloads, called with an object variable holding an Order.
Why it is wrong: overload resolution is compile-time; Handle(object) runs.
Do instead: use virtual methods, interfaces or pattern matching for runtime decisions.
What happens: the same switch on type or a string discriminator is duplicated across services.
Why it is wrong: adding a type means finding every switch; one is always missed.
Do instead: use polymorphism (interfaces/strategies) for open sets, or centralise one switch for closed sets.
What happens: domain result types each get virtual ToHttpResponse() methods.
Why it is wrong: mixes presentation concerns into domain types.
Do instead: use a single pattern-matching switch in the presentation layer.
new and calling it polymorphismWhat happens: derived methods declared with new.
Why it is wrong: dispatch is static; base references call the base method.
Do instead: use virtual/override.
What happens: tight numeric loops call virtual/interface methods per element. Why it is wrong: prevents inlining and vectorisation. Do instead: seal classes, use generic constraints with structs, or batch work.
What happens: passing a struct to a method taking IComparable.
Why it is wrong: boxing on every call plus interface dispatch.
Do instead: use generic methods constrained to the interface.
dynamic for runtime dispatchWhat happens: Handle((dynamic)message) to pick overloads.
Why it is wrong: slow, fails at run time when an overload is missing, and doesn't work with trimming/AOT.
Do instead: use pattern matching or a handler registry.
What happens: candidates describe polymorphism purely as virtual methods. Why it is wrong: misses generics, overloading, interfaces and static abstract members. Do instead: describe compile-time and run-time forms and their trade-offs.
Seniors choose the form of polymorphism by asking what changes more often: the set of types or the set of operations.
| Situation | Choice |
|---|---|
| Plugins, carriers, providers added over time | interface + DI registration |
| Closed set of message or result types | records + exhaustive switch |
| Algorithms over many numeric types | generics with INumber<T> |
| Small behaviour variation | delegate or strategy |
| Hot path with one dominant type | sealed types, let PGO devirtualise |
They also mention that polymorphism moves decisions to where the knowledge lives, which reduces coupling; the goal is not class hierarchies but fewer places that need to change.
Inheritance lets a derived class reuse and specialise a base class: class SavingsAccount : Account. C# allows one base class but many interfaces. The derived class inherits all members except constructors and finalisers (private ones exist but are not accessible), can call base members with base., and can override virtual/abstract members. Declaring a member with the same name without override hides it (new), and which one runs then depends on the variable's static type, a common source of bugs. Use inheritance only for a real is-a relationship where a derived object can be used anywhere the base is expected (Liskov substitution). Deep hierarchies, inheriting just to reuse code, and fragile base classes are the main problems; composition is often better.
A method is a named block of code with a signature: its name plus the number, types, order and ref/out/in kinds of its parameters (the return type is not part of it, so you cannot overload by return type alone). Instance methods run on an object and can use this; static methods belong to the type and cannot. Overloading means several methods with the same name and different signatures; the compiler picks the best match at compile time, preferring exact types over conversions and normal forms over params. Expression-bodied methods (=> expr) are short forms for one expression. Extension methods are static methods with a this parameter that look like instance methods; an instance method always wins if one fits. C# 14 adds extension blocks, which also allow extension properties and static extension members.
How to tell it (STAR)
**Situation:** At [company] adding a shipping carrier took weeks, and twice a missed switch case broke label creation in production. **Task:** I proposed and led a redesign so carriers could be added safely. **Action:** I found the same enum switch in five places with different defaults. I moved each carrier's behaviour into a class implementing `ICarrier`, registered them in DI, and replaced the switches with polymorphic calls. I wrote a shared contract test that every carrier must pass. **Result:** The next carrier was added as one new class in [three days], with no changes to existing services, and we have had no missed-case incidents since.
Last updated 30 Oct 2026.