Polymorphism in computer science is the ability of a single piece of code to work with values of different types. The word comes from Greek, meaning “many forms,” and the concept shows up everywhere in programming: a function that sorts a list should not need to be rewritten from scratch for lists of numbers, lists of strings, and lists of dates. Polymorphism is what lets you write that function once and have it handle all three. The idea sounds simple, but the ways programming languages achieve it, and the tradeoffs involved, vary enormously.
The Classic Taxonomy
The most widely referenced classification of polymorphism in computer science comes from a framework that divides it into two broad camps: universal polymorphism and ad-hoc polymorphism. In universal polymorphism, the same code genuinely operates over many types. In ad-hoc polymorphism, different code may run for different types, even though the programmer uses the same name or syntax to invoke it.1Overload (ACCU). Polymorphism In Object-Oriented Languages That distinction matters because it determines how much the compiler or runtime has to do behind the scenes, and what guarantees you get as a programmer.
Universal polymorphism further splits into parametric polymorphism and inclusion (or subtype) polymorphism. Ad-hoc polymorphism splits into overloading and coercion. A foundational paper by Cardelli and Wegner traced the evolution of programming languages from untyped systems to monomorphic (one type per value) systems to polymorphic ones, and showed how overloading, coercion, subtyping, and parameterization all fit into a single conceptual framework.2ACM Computing Surveys. On understanding types, data abstraction, and polymorphism Most modern languages use several of these mechanisms at once, which is part of why the topic can feel confusing at first.
Parametric Polymorphism
Parametric polymorphism is what most programmers encounter as “generics” or “templates.” You write a function or a data structure with a placeholder for the type, and the language fills in the actual type when you use it. A generic list in Java, a template function in C++, or a generic struct in Rust all use parametric polymorphism. The code does not care what the type is; it operates on the shape of the data rather than the specifics.
The appeal is obvious: you write one sorting algorithm that works for integers, floating-point numbers, strings, or any custom type that supports comparison. You get a single, tested implementation instead of many near-copies. The compiler checks that the types line up, so errors get caught before the program runs.
What differs across languages is how the compiler turns that generic code into something the machine can execute. There are two main strategies. The first, called monomorphization, creates a separate copy of the code for each type it is actually used with. If your generic function is called with integers and with strings, the compiler generates two specialized versions. This produces fast code because each version is tailored to one type, but it can increase the size of the compiled program. The second strategy, called erasure, uses a single version of the code that handles all types through a uniform representation, which avoids code bloat but introduces performance costs like boxing primitive values into heap-allocated objects.3Proceedings of the ACM on Programming Languages. TASTyTruffle: Just-in-Time Specialization of Parametric Polymorphism
Java is the most prominent example of erasure: its generics are essentially a compile-time convenience that gets stripped away before the program runs. The JVM sees raw, unparameterized types at runtime. C++ takes the opposite approach with templates, generating specialized machine code for every instantiation. Rust does the same with its generics. Go, which added generics more recently, uses a hybrid approach that can be thought of as an optimized form of monomorphization, statically generating specialized methods and types based on the instantiations that actually appear in the program.4Proceedings of the ACM on Programming Languages. Generic go to go: dictionary-passing, monomorphisation, and hybrid
Research into recovering the performance lost by erasure is active. One line of work uses just-in-time compilation to specialize generic code at runtime, producing monomorphic copies that the JIT compiler can then optimize aggressively. This gives you the smaller binary of erasure with performance closer to full monomorphization.5Proceedings of the ACM on Programming Languages. TASTyTruffle: Just-in-Time Specialization of Parametric Polymorphism
Subtype Polymorphism
Subtype polymorphism, sometimes called inclusion polymorphism, is the kind most associated with object-oriented programming. The idea is that if type B is a subtype of type A, then anywhere the program expects an A, you can hand it a B instead. In class-based languages like Java or C#, this typically happens through inheritance: a class Dog that extends Animal can be used wherever an Animal is expected.
This sounds straightforward, but getting it right is harder than it looks. The question is what “can be used wherever” actually promises. The strongest guarantee is behavioral: any property you can prove about the supertype should also hold for the subtype. A landmark paper by Liskov and Wing formalized this idea, arguing that the subtype relation should be defined in terms of the specifications of the types involved, not just their structure.6ACM Transactions on Programming Languages and Systems. A behavioral notion of subtyping This is the principle most programmers know as the Liskov Substitution Principle, and violating it is a common source of bugs in object-oriented code. A classic example: a Square class that inherits from Rectangle might seem logical geometrically, but if Rectangle has methods that independently set width and height, Square cannot honor that contract without breaking its own invariant.
A subtler issue arises with generic types. If Dog is a subtype of Animal, is a list of Dogs a subtype of a list of Animals? The answer depends on whether the list is read-only, write-only, or read-write. A read-only list of Dogs is safe to treat as a list of Animals (covariance), but a writable one is not, because you could insert a Cat into what is actually a Dog list. Languages handle this differently. Java uses wildcards, Kotlin has declaration-site variance annotations, and Scala supports both. Research on variant parametric types has formalized how variance annotations on type arguments can control both the subtyping relationships between generic types and the accessibility of their members.7ACM Transactions on Programming Languages and Systems. Variant parametric types
Ad-Hoc Polymorphism
Ad-hoc polymorphism covers the cases where the same name or operator does different things depending on the types involved, but there is no single underlying generic implementation. The two most common forms are overloading and coercion.
Overloading means having multiple functions with the same name but different parameter types. In Java, you can write several versions of a method called print, one taking an integer, one taking a string, one taking a float. The compiler picks the right one based on the argument types at the call site. This seems simple enough, but when you add implicit type conversions and default parameters to the mix, the resolution rules get surprisingly complicated. Different languages resolve ambiguities in very different ways: C++ uses a conversion-rank hierarchy, Java uses a three-phase method invocation procedure, C# has its own rules around optional parameters and user-defined conversions, and Ada propagates expected types top-down through expressions.8Iconic Research And Engineering Journals. Formal Analysis of Overloaded Subprogram Resolution Rules: Ambiguity, Coercion, and Default Parameters in C++, Java, C#, and Ada These differences mean that code that compiles cleanly in one language might be ambiguous in another, even if the function signatures look identical.
Coercion is the other form: the language automatically converts a value from one type to another so that an operation can proceed. When you add an integer to a floating-point number in most languages, the integer gets silently promoted to a float. This is polymorphism in the loose sense that the addition operator works on mixed types, but it is not the same code running. The compiler inserts a conversion step. Implicit coercion can be convenient, but it is also a well-known source of surprises, particularly in languages like JavaScript where the coercion rules are extensive and sometimes counterintuitive.
Type Classes and Constrained Polymorphism
Haskell introduced a mechanism that blurs the line between parametric and ad-hoc polymorphism in a productive way: type classes. A type class defines an interface, a set of operations, and any type that provides implementations of those operations becomes a member of the class. A function constrained by a type class is parametrically polymorphic in that it works for any type, but only types that are members of the class. The implementation of type classes in the Haskell compiler extends the ML type system to support overloading in a way that handles things like equality testing and numeric operations cleanly.9ACM SIGPLAN Notices. Implementing type classes
Rust’s traits are a similar mechanism with a different name. So are Swift’s protocols and, to some extent, Go’s interfaces. The common thread is that you can write generic code that is not totally unconstrained but instead requires its type parameters to support specific operations. This gives you the flexibility of generics with the guarantees of a defined contract.
Recent research has pushed these ideas further, exploring how type classes can be combined with extensible data types. One line of work proposes combining row types (a way of describing record-like structures with flexible fields) with type-class-based ad-hoc polymorphism, allowing programmers to define data types that can be extended with new variants without modifying existing code.10Proceedings of the ACM on Programming Languages. Extensible Data Types with Ad-Hoc Polymorphism This is aimed at a longstanding challenge in language design sometimes called the “expression problem”: how to add both new types and new operations to a system without rewriting what already exists.
Dynamic Dispatch and Its Costs
Subtype polymorphism requires some mechanism for figuring out, at runtime, which version of a method to call. If you have a variable of type Animal that actually holds a Dog, and you call its speak() method, the program needs to find Dog’s implementation of speak() rather than any default on Animal. This is called dynamic dispatch.
In statically typed languages like C++ and Java, the standard mechanism is a virtual function table (vtable): each object carries a pointer to a table of function pointers, and calling a virtual method means following one pointer to the table and another to the actual code. In dynamically typed languages, dispatch is more complex because the type of a value is not known until the program is running. One well-studied technique uses row displacement tables to implement message dispatching with a runtime cost of one memory indirection plus an equality test, which is comparable to vtable lookup but works without static type information.11ACM SIGPLAN Notices. Minimizing row displacement dispatch tables
Dynamic dispatch has a real performance cost. Every virtual call is an indirect jump, which is harder for the processor to predict and optimize than a direct function call. Modern JIT compilers fight back with devirtualization: if the runtime can determine that a particular call site always targets the same concrete type, it can replace the indirect call with a direct one or even inline the method body entirely. This optimization is especially important in Java, where nearly all method calls are virtual by default.
Polymorphism in Dynamic Languages
Languages like Python, Ruby, and JavaScript take a fundamentally different approach. Because variables do not have fixed types, any value of any type can be passed anywhere. This is sometimes called duck typing: if it walks like a duck and quacks like a duck, the language treats it as a duck. You do not declare that a function accepts a specific type or interface; you just call the methods you need, and if the object supports them, it works.
This gives you extreme flexibility but shifts the burden of correctness from the compiler to the programmer (and the test suite). You get no compile-time guarantee that a function will not blow up because someone passed it an unexpected type. Dynamic languages make heavy use of runtime type tests, higher-order functions, and dictionary-like structures that go beyond what traditional static type systems can express.12ACM SIGPLAN Notices. Nested refinements
The trend in recent years has been toward hybrid approaches. TypeScript adds optional static types to JavaScript. Python’s type hints let you annotate your code without the language enforcing the annotations at runtime. Gradual typing systems aim to let you start with untyped “structural” code and incrementally add typed “nominal” code, with the language ensuring that the typed and untyped portions interact safely.13Proceedings of the ACM on Programming Languages. Transitioning from structural to nominal code with efficient gradual typing The goal is to give programmers the flexibility of dynamic languages early in development and the safety of static types as a codebase matures.
Row Polymorphism and Structural Typing
Most of the polymorphism discussed so far is nominal: types are identified by their names. Dog is a subtype of Animal because the programmer explicitly declared that relationship. Structural typing takes a different view: a type is defined by its structure (what fields and methods it has), not by its name. Two types with the same fields are compatible, regardless of whether they share any declared relationship.
Row polymorphism is a formal mechanism for making this work with record-like types. A row-polymorphic function can accept any record that has certain fields, without caring what other fields the record might also have. This is useful for languages that want structural flexibility without giving up type safety. Recent work has studied how row polymorphism interacts with set-theoretic types like unions, intersections, and negation types, defining a calculus with operations for adding, reading, and removing fields from records while maintaining type soundness.14Proceedings of the ACM on Programming Languages. Polymorphic Records for Dynamic Languages
Go’s interfaces are a practical example of structural subtyping in a mainstream language: a type satisfies an interface if it has the right methods, with no explicit declaration needed. TypeScript also uses structural typing for its object types. The appeal is reduced boilerplate and looser coupling between components, at the cost of sometimes surprising type compatibility (two unrelated types might accidentally satisfy the same interface).
Common Misconceptions
One widespread confusion is equating polymorphism with inheritance. Inheritance is one mechanism for achieving subtype polymorphism, but polymorphism is a much broader concept. Parametric polymorphism requires no inheritance at all. Ad-hoc polymorphism through overloading has nothing to do with class hierarchies. Even subtype polymorphism can be achieved through interfaces or structural typing rather than class inheritance. Thinking of polymorphism as “just an OOP thing” misses most of the picture.
Another misconception is that generics and templates are the same thing. They achieve similar goals but work very differently. Java generics use type erasure and impose restrictions (you cannot create a generic array, for instance) that stem from the JVM’s lack of runtime type information for generic types. C++ templates are essentially a compile-time code generation system with no such restrictions, but they can produce cryptic error messages and slow compilation. Rust’s generics are monomorphized like C++ templates but work within a trait-based system that gives clearer error messages and more predictable behavior. The language you are using determines what polymorphism actually means in practice.
A third common misunderstanding involves performance. Polymorphism is sometimes treated as inherently slow, as if every virtual method call or generic function carries a heavy penalty. In reality, the costs are highly situation-dependent. Monomorphized generics have zero runtime overhead compared to hand-written specialized code. Virtual calls add a small cost that JIT compilers often eliminate through devirtualization and inlining. The performance story depends on the language, the compiler, and the specific pattern of usage, not on polymorphism as an abstract concept.
Where Language Design Is Heading
The boundaries between the classic categories of polymorphism have been blurring for years. Haskell’s type classes mix parametric and ad-hoc polymorphism. Gradual typing systems mix static and dynamic approaches. Row polymorphism gives structural typing the formal rigor that was once reserved for nominal systems. Languages increasingly offer multiple polymorphism mechanisms and let the programmer choose the right one for the task.
One active area of research involves combining existential types (which hide the concrete type behind an abstract interface) with bounded quantification (which constrains type parameters to subtypes of some bound). Cardelli and Wegner’s foundational framework already identified these as key building blocks: universal quantification for generic functions, existential quantification for data abstraction, and bounded quantification for modeling subtypes and inheritance.15ACM Computing Surveys. On understanding types, data abstraction, and polymorphism Modern languages are still working out how to expose these powerful ideas in forms that ordinary programmers can use without a PhD in type theory. Features like Rust’s trait objects, Swift’s opaque return types, and Kotlin’s reified type parameters are all attempts to give programmers access to sophisticated polymorphism without requiring them to think in formal terms. The trajectory is clear: more polymorphism mechanisms, more interoperability between them, and better tooling to manage the complexity.

