AlgoMaster Logo

Compile-Time Polymorphism

Last Updated: May 17, 2026

13 min read

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.

What "Compile-Time" Actually Means

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:

  • Method overloading: multiple methods sharing a name, distinguished by their parameter lists.
  • Constructor overloading: multiple constructors on the same class, distinguished the same way.
  • Operator overloading: providing custom meanings for operators like +, ==, 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

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.

What Counts as a Unique Signature

Two overloads are considered different when their parameter lists differ in any of these ways:

  • Number of parameters is different.
  • Types of parameters are different in some position.
  • Order of types is different.
  • Modifiers like ref, out, or in differ.

Two things specifically do not make a method unique:

  • The return type alone. Two methods that differ only in what they return are not valid overloads. The compiler reports error CS0111.
  • The parameter names. Renaming a parameter from 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.

Overload Resolution Rules

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:

  1. Build the candidate set. All methods in scope with the right name and a parameter count the call could supply (counting optional parameters and params).
  2. Remove inapplicable candidates. Drop any candidate whose parameters can't accept the supplied argument types (even via implicit conversion).
  3. Prefer exact matches. If a candidate matches every argument type exactly without conversion, it beats candidates that need conversions.
  4. Prefer better conversions. Between candidates that need conversions, the compiler picks the one with the "more specific" conversions. For numeric types, int -> long is preferred over int -> double, because long is closer to int on the conversion graph.
  5. Treat `params` and optional parameters as last resorts. A normal parameter list match is preferred over a params expansion or a defaulted optional parameter.
  6. If no single candidate is best, the call is ambiguous. The compiler emits error 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 Errors

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:

Constructor Overloading

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.

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

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:

  • You have a type that behaves like a numeric value: money, quantity, weight, currency conversion factor.
  • Equality on the type follows a clear rule from the type's data (two Money values are equal when both the amount and currency match).
  • Arithmetic between instances has an obvious result.

When to skip it:

  • The type doesn't behave like a value (a ShoppingCart plus another ShoppingCart has no single sensible meaning).
  • You'd surprise readers by giving an operator a non-obvious behavior. Cute uses of + 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.
  • Whenever you overload == 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.
  • Operator overloads are a special form of overloading. C# uses the operand types (just like argument types in method overloading) to pick which operator method runs. Money + Money looks at the operator overloads on Money and finds a match for (Money, Money).

Optional Parameters and Named Arguments

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.

AspectMethod OverloadsOptional Parameters
Number of methodsOne method per parameter combinationOne method, several call shapes
Where default livesIn each overload's body, explicitBaked into the parameter declaration
Default value compiled intoThe classThe caller, at the call site
VersioningChanging a default in a new method version affects only new callersChanging a default requires recompiling every caller; old binaries keep using the old default
Resolution preferenceNormal overloads beat overloads using optional defaultsOptional parameter forms are considered after exact-arity overloads
Polymorphism through derived overridesPossible (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.

Overloads or Optional Parameters?

Both let callers omit arguments, so the choice can feel like a coin flip. A workable rule:

  • Internal app code where everything compiles together: optional parameters are fine and reduce the number of methods.
  • Public APIs of a library you ship to others: prefer overloads, especially for parameters whose default you might want to change later.
  • Parameters whose values are semantically different states (not just defaults): prefer overloads, because each overload can have a clearer name in IntelliSense and a body that handles its case directly.
  • Parameters that are honestly "default this 90% of the time": optional parameters keep the call site short.

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.

Resolution Corner Cases

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 Argument

A 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.

Mixed Numeric Types

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 Array

A 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.

A Misleading Overload

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.

Summary

  • Compile-time polymorphism resolves a call based on the static types at the call site. The compiler picks one method, constructor, or operator overload and bakes the choice into the IL. The runtime doesn't participate.
  • Overloads are distinguished by their parameter list: number, types, order, and modifiers (ref/out/in). Return type alone is not enough, and the compiler reports CS0111 if you try.
  • Overload resolution prefers exact matches over implicit conversions, prefers the most specific conversion when several are possible, and treats params and optional parameter defaults as last resorts.
  • Ambiguous calls (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.
  • Constructor overloading uses the same rules. Chain with : this(...) to keep assignment logic in one constructor and let the others delegate. Past four or five constructors, consider a builder pattern instead.
  • Operator overloads must be 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.
  • Optional parameters bake their defaults into the caller's compiled IL, which makes them safe inside a single app but risky in public library APIs where callers compile separately. Overloads keep the default in the library.
  • Recognize when two operations don't belong under the same method name. Compile-time polymorphism is for closely related operations on the same kind of data, not for unrelated behaviors that share a verb.

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.