Last Updated: May 17, 2026
Polymorphism is "one name, many forms" and comes in two flavors: compile-time and runtime. This chapter focuses on the compile-time half. With compile-time polymorphism, the C# compiler picks which method runs by looking at the call site before your program ever starts, using only the static types of the call and its arguments. The same name on a method, constructor, or operator can resolve to different implementations depending on what you pass in, and once you understand the resolution rules, the outcomes stop being surprises.
Every method call in C# has two type contexts. The first is the declared type of the variable or expression you're calling the method on. The second is the types of the arguments you pass. With compile-time polymorphism, both come from the source code alone. The compiler reads the call, looks at every candidate method with that name, and picks one using a deterministic set of rules. The runtime doesn't get a vote.
Other names for the same thing are static binding and early binding. "Static" because the choice is fixed at compile time. "Early" because it happens before execution, in contrast with virtual dispatch, which decides later, when the actual object on the heap is available.
The three C# features that participate in compile-time polymorphism are:
+, ==, or - on your own types.Each one boils down to the same compiler job: given a name and a set of arguments, find the best match.
Two methods named Add, two call sites, two different bodies executed. The compiler matched each call to a method based on the argument list alone. Nothing about a derived class, a virtual table, or a runtime object came into play.
Method overloading lets a class expose several methods that share a name but accept different parameter lists. The compiler treats each one as a separate method internally; the shared name is just a convenience for callers.
The benefit is that closely related operations look the same to the person reading the code. A Cart doesn't need three method names like AddProduct, AddProductWithQuantity, and AddProductsBatch if "add" is the right verb for all three.
Three overloads, three call sites, each unambiguous. The compiler picked an overload at each call by comparing the argument types to each candidate's parameter list.
Two overloads are considered different when their parameter lists differ in any of these ways:
ref, out, or in differ.Two things specifically do not make a method unique:
CS0111.quantity to count doesn't create a new overload, because callers identify methods by parameter types, not parameter names.That doesn't compile, because the parameter list is identical and the compiler can't disambiguate based on return type alone. If both methods were callable, an expression like inventory.Lookup("ABC") would have no way to pick one.
What does work:
Each method has a different parameter list (number, types, or out modifier), so each is a legal overload.
Once the compiler has a set of candidate overloads for a call, it has to pick one. The process happens in stages, and understanding the order is the difference between "this just works" and "why won't this compile?"
The stages, simplified:
params).int -> long is preferred over int -> double, because long is closer to int on the conversion graph.params expansion or a defaulted optional parameter.CS0121.A small example shows steps 3 and 4 in action.
The first three calls each have an exact match. The interesting one is the fourth: s is a short. None of the overloads take a short exactly, so the compiler considers implicit conversions. A short can convert implicitly to int, long, or double. Of those, int is the "closest" target, but there's no int overload either, so the next closest is long. The compiler picks Quote(long), and the call resolves cleanly.
A flowchart helps. This is what the compiler does for one call:
The diagram is a useful mental model when a call won't compile. Walk down the branches: did the call have any applicable candidates? If not, the argument types are wrong. If yes, was there an exact match? If yes, that's what got picked. If not, the compiler had to weigh conversions, and that's where ambiguity errors come from.
Ambiguity (CS0121) shows up when two or more candidates are equally good. The compiler refuses to guess.
The exact error:
Both candidates require one argument to be converted from int to double. Neither overload is strictly better than the other, since each loses on exactly one parameter. The fix is to be explicit at the call site or change the overloads so one is clearly preferred:
Another common cause of ambiguity is mixing reference types that share base classes or interfaces. If you have void Notify(object o) and void Notify(string s) and you call Notify(null), the compiler can pick: string is more specific than object, so Notify(string) wins. But add a third overload, void Notify(StringBuilder sb), and a null argument becomes ambiguous between string and StringBuilder because the compiler can't pick a most-specific reference type.
To disambiguate, cast the null:
Constructors follow the same overloading rules as methods. A class can offer several constructors, each accepting a different parameter list, and the compiler picks one based on the arguments at the new expression.
The point of overloading constructors is to give callers flexible ways to build an object. Some callers have only a name, others have a full set of details. Each combination becomes its own constructor.
The : this(...) syntax is constructor chaining: each constructor delegates to another constructor in the same class to avoid repeating the assignment code. The fourth constructor does the real work, and the first three forward to it with sensible defaults. The goal here is just to recognize the syntax in the overloading context.
Cost: Heavy constructor overloading on a single class is a sign you might want a builder pattern instead. Once you're past 4 or 5 overloads, callers have a hard time picking which one to use, and small parameter changes ripple through every overload.
The same overload resolution rules apply at new expressions:
The first call passes an int where the constructor expects a decimal. The compiler converts implicitly (int to decimal is widening) and picks the three-argument constructor.
Operator overloading lets your own types respond to built-in operators like +, -, ==, and != in a way that makes sense for the type. The classic example in an online store is money: adding two money values should give you a money value, not two separate fields you have to add by hand.
When to consider it:
Money values are equal when both the amount and currency match).When to skip it:
ShoppingCart plus another ShoppingCart has no single sensible meaning).+ to mean "append to log" age badly.Operators in C# must be declared public static. The public part is required because operators are looked up by external callers, even when called through an instance. The static part is required because operators belong to the type, not to a specific instance: the call site a + b doesn't have a clear "receiver" object.
Here's a Money type that overloads +, -, ==, and != correctly, including the BCL Equals/GetHashCode overrides that you should always do alongside ==/!=.
A few details that catch people off guard the first time:
== and != must be overloaded as a pair. If you define one without the other, the compiler emits CS0216.== and !=, also override Equals(object) and GetHashCode. Otherwise hash-based collections like HashSet<Money> or Dictionary<Money, X> will misbehave, because their lookups use Equals and GetHashCode, not the operator.Money + Money looks at the operator overloads on Money and finds a match for (Money, Money).Cost: Each + call on Money allocates a new Money instance because the type is immutable. That's the right design for value-like types, but a tight loop adding thousands of Money values in a row will produce that many short-lived copies. It's fine in most code, just don't write Money arithmetic inside a CPU-hot inner loop without checking.
Optional parameters give you another way to support multiple call shapes without writing many overloads. A parameter becomes optional when you give it a default value in the declaration:
The last call uses a named argument (giftWrap: true) to skip the middle optional parameter while still passing the third one. Named arguments make the code easier to read and let you supply parameters in any order, as long as positional arguments come first.
Optional parameters look similar to overloading, but they're not the same mechanism, and the differences matter.
| Aspect | Method Overloads | Optional Parameters |
|---|---|---|
| Number of methods | One method per parameter combination | One method, several call shapes |
| Where default lives | In each overload's body, explicit | Baked into the parameter declaration |
| Default value compiled into | The class | The caller, at the call site |
| Versioning | Changing a default in a new method version affects only new callers | Changing a default requires recompiling every caller; old binaries keep using the old default |
| Resolution preference | Normal overloads beat overloads using optional defaults | Optional parameter forms are considered after exact-arity overloads |
| Polymorphism through derived overrides | Possible (you can add overloads in subclasses) | The default lives on the declared parameter; overrides don't change it |
The versioning point is the one most teams learn the hard way. When you write:
and a caller in another assembly writes cart.Charge(20m), the C# compiler substitutes `"USD"` into the caller's IL at the call site. The caller's compiled binary literally contains Charge(20m, "USD"). If you later change the default to "EUR" and ship a new version of your library, callers that haven't been recompiled still use "USD". They're frozen at the old default, which can cause confusing behavior in production where caller and callee versions get out of sync.
For internal code that's all built together, optional parameters are convenient and harmless. For public library APIs where callers compile separately, overloads are safer because the default lives in the library, not in the caller.
Another nuance: optional parameter values must be compile-time constants (null, primitives, or enums for reference types) or default expressions like default. You can't write int x = DateTime.Now.Year, because the value has to be embeddable in IL.
What's wrong with this code?
DateTime.Now isn't a compile-time constant, it's a property whose value depends on when the program runs. The compiler reports CS1736. Use overloading instead, or fall back to a sentinel:
Fix:
null is a valid compile-time constant, and the method falls back to the current time at runtime if the caller didn't supply one.
Both let callers omit arguments, so the choice can feel like a coin flip. A workable rule:
Mixed approaches are common too. A public API might offer two or three overloads, each of which internally has a single optional parameter for a less important toggle. The trick is to design for the readability of the call site.
A few patterns trip up the resolution rules in subtle ways. They're worth seeing once so you recognize them when they show up.
null as an ArgumentA literal null has no type until the compiler picks one. When several overloads accept reference types, the compiler tries to pick the most specific. string is more specific than object; IEnumerable<int> is more specific than object. But two unrelated reference types (string and StringBuilder, Customer and Address) are at the same level of specificity, and a bare null becomes ambiguous.
Casting the null to a specific type pins down which overload the compiler picks.
C# defines a graph of implicit numeric conversions: int to long, int to float, int to double, and so on. When several overloads accept numeric types, the compiler walks the conversion graph and picks the "closest" target.
The last call is the interesting one. short doesn't have an exact match. The conversion graph offers short -> int -> long -> double and short -> decimal (yes, short converts implicitly to decimal as well). Among those, int is one step away, so the compiler picks the int overload.
What about float? float converts implicitly to double but not to decimal, and not to int or long without an explicit cast. So p.Quote(10f) would resolve to Quote(double).
params vs Explicit ArrayA params parameter lets callers pass a variable number of arguments without manually constructing an array:
The three calls compile to the same method, just with different array constructions at the call sites. Now imagine an overload pair where one takes params string[] and another takes a single array explicitly:
The compiler rejects that because the two signatures collapse to the same string[] parameter type after params expansion. params is a calling convention sugar, not a different type.
But here's a case where params matters for resolution:
When a single argument matches both Send(string) and Send(params string[]) (via a one-element array), the non-`params` overload wins. This is part of step 5 in the resolution rules: normal parameter list matches beat params expansions.
What's wrong with this code?
The caller probably meant "apply a 20% discount" and might be surprised when the code prints Applied coupon with id 20. The compiler picked Apply(int) because 20 is an int literal and the int overload is an exact match. The double overload would only have been picked for 20.0 or 0.20.
The bug isn't the resolution rules: those did exactly what they should. The bug is that two semantically different operations were given the same name. A reader has to know both signatures and the call's literal type to predict what runs.
Fix: Rename the methods so the call site communicates intent. Compile-time polymorphism is a tool for closely related operations, not for unrelated ones that happen to share a verb.
When in doubt, use different names. Overloading isn't free, every overload you add increases the surface area readers must hold in their heads.
ref/out/in). Return type alone is not enough, and the compiler reports CS0111 if you try.params and optional parameter defaults as last resorts.CS0121) happen when no candidate is strictly better than another. Casting a null argument or making one argument more specific is usually enough to disambiguate.: this(...) to keep assignment logic in one constructor and let the others delegate. Past four or five constructors, consider a builder pattern instead.public static. == and != must be defined as a pair, and overriding Equals and GetHashCode alongside them is required for hash-based collections to behave correctly.The _Runtime Polymorphism_ lesson shifts the dispatch decision from compile time to runtime. Instead of the compiler picking a method based on the reference type, the CLR picks it based on the object's actual type at the moment of the call. That's where virtual, override, and the method table earn their keep.