One implementation, many types: the core idea behind modern abstraction
This article explains the pattern behind “a single implementation that can work with different types, as long as each type provides the operations it needs” — the core of generics, typeclasses, traits, and interfaces across modern languages.
1. The core idea
Many languages let you write a single implementation that can operate on values of different types, as long as those types provide certain behavior. You get reuse without copy-paste, and safety without losing flexibility.
At a high level, the pattern looks like this:
The function does not care whether T is an integer, a string, or a custom struct;
it only cares that T supports the operations it needs (for example, comparison).
2. Core terminology and mental model
Different languages give different names to the same underlying idea. The words can obscure how similar the concepts really are, so this section aligns them.
2.1. Parametric polymorphism (generics)
Parametric polymorphism means a function or type is written with a
type parameter instead of a concrete type. You see this typically as
<T>, <A>, or similar.
// Generic function signature (language-neutral sketch)
function identity<T>(value: T): T {
return value;
}
Here, identity doesn’t know anything about T except that it should
return the same type it receives. You can pass an integer, a string, or any other type, and the
function works in the same structural way.
2.2. Ad-hoc polymorphism (typeclasses, traits, interfaces)
Ad-hoc polymorphism is achieved by describing constraints or capabilities that a type must satisfy to be used with some function. This is where typeclasses, traits, and interfaces come in.
// Conceptual sketch: a type that can be "compared"
interface Comparable<T> {
compareTo(other: T): number; // negative, zero, or positive
}
// Generic function restricted to comparable types
function sort<T extends Comparable<T>>(list: List<T>): List<T> {
// ... sorting logic that uses compareTo ...
}
Any type that provides a compatible compareTo implementation can be sorted. The
sorting algorithm is written once, but is usable for many types.
2.3. Capabilities vs. concrete types
The central mental model:
- Traditional, concrete-style code: “This function works on integers.”
- Capability-based generic code: “This function works on any type that knows how to compare itself.”
You move from “what the data is” to “what the data can do.”
3. How different languages express the same pattern
Most modern languages support a variation of this pattern. The syntax differs, but the structure and purpose are similar.
| Language | Type parameter mechanism | Capability mechanism | Example keyword |
|---|---|---|---|
| Haskell | Parametric polymorphism | Typeclasses | class, instance |
| Rust | Generics | Traits | trait, impl |
| Java | Generics | Interfaces | interface, implements |
| C# | Generics | Interfaces / constraints | interface, where T : IFoo |
| C++ | Templates | Concepts (C++20+) / traits | template, concept |
| Swift | Generics | Protocols | protocol, where |
| TypeScript | Generics | Structural constraints | <T extends Something> |
3.1. Haskell-style typeclasses
Haskell separates the definition of behavior (typeclasses) from the specific types that implement that behavior (instances).
-- A typeclass describing types that can be turned into a String
class Show a where
show :: a -> String
-- An instance: Int knows how to "show" itself
instance Show Int where
show x = "Int(" ++ intToString x ++ ")"
-- Generic function that works for any "showable" type
printValue :: Show a => a -> IO ()
printValue x = putStrLn (show x)
The function printValue is written once but works for any type that has a
Show instance. The compiler ensures that you can only call
printValue with types that satisfy the Show constraint.
3.2. Rust-style traits
Rust traits are conceptually very similar to Haskell typeclasses. They describe behavior that types can provide, and generic functions can be constrained by those traits.
trait Area {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Area for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
// Generic function over any type that implements Area
fn print_area<T: Area>(shape: &T) {
println!("Area is {}", shape.area());
}
print_area does not care whether it receives a Circle or
Rectangle. It only requires that the type implements Area.
3.3. Java-style interfaces with generics
Java combines generics and interfaces to achieve the same pattern.
public interface Serializer<T> {
String serialize(T value);
}
public class JsonSerializer<T> implements Serializer<T> {
@Override
public String serialize(T value) {
// ... convert value to JSON ...
return "...";
}
}
// Generic method constrained by an interface
public <T> void sendSerialized(T value, Serializer<T> serializer) {
String payload = serializer.serialize(value);
// send payload over the network
}
The method sendSerialized works for any type T as long as a compatible
Serializer<T> instance is provided.
4. The structural pattern behind “one implementation, many types”
No matter the language, you can usually decompose the pattern into four conceptual pieces:
1. A generic function
A function written in terms of a type parameter T rather than a specific type.
This function expresses the algorithm: sorting, mapping, serializing, etc.
fn process<T>(value: T) {
// algorithm that works for any T, with constraints
}
2. A capability constraint
A description of what T must be able to do (compare, serialize, hash, etc.).
This is where typeclasses, traits, interfaces, and protocols come into play.
trait Printable {
fn to_string(&self) -> String;
}
3. Concrete implementations
Actual types that implement or satisfy those capabilities, such as
Printable for i32, String, or a custom struct.
impl Printable for i32 {
fn to_string(&self) -> String {
format!("i32: {self}")
}
}
4. Automatic or explicit wiring
The language either automatically resolves the correct implementation (Haskell, Rust) or you pass the instance explicitly (Java, C# in some designs).
fn print_any<T: Printable>(x: &T) {
println!("{}", x.to_string());
}
4.1. Abstract vs. concrete layers
It’s useful to view this as layering:
The generic layer never “looks down” to see if the concrete type is an integer, string, or custom object. It only sees the capability layer.
5. Why this pattern exists and why it matters
5.1. Purpose
The main purpose is to write reusable, type-safe, and extensible code without repeating yourself.
- Reusability: One sorting function, not a different one for ints, doubles, strings, and every domain type.
- Type safety: The compiler ensures you only use the function with types that provide the required capabilities.
- Extensibility: New types can “opt in” by providing the necessary implementations without modifying the original generic function.
5.2. Advantages
a) Less duplication, more consistency
Without generics and typeclasses/traits, you’d often end up copying the same algorithm for different types. That’s:
- Error-prone: subtle logic bugs creep into one variant.
- Hard to maintain: fixes must be propagated everywhere.
- Slower to evolve: adding support for new types requires new copies.
b) Better abstraction boundaries
By programming against capabilities, you isolate the generic algorithm from the specifics of each type. This makes:
- Testing easier: you can test the algorithm separately from type implementations.
- Refactoring safer: you can change the concrete type representation without changing generic code.
- Design cleaner: dependencies are expressed via clear capability contracts.
c) Improved composition
Many powerful abstractions (functors, monads, iterators, futures, etc.) rely heavily on this pattern. They allow you to build complex behaviors by composing smaller, generic pieces, without losing type information.
5.3. Historical context
Historically, languages started with very concrete functions:
- One version for integers, one for floats, another for custom types.
- No type parameters; all types were explicitly encoded into function names or overloads.
Over time, several ideas converged:
- Parametric polymorphism (ML, System F, then many languages).
- Typeclasses (Haskell) as a structured way to bundle capabilities.
- Generics with constraints (Java, C#, etc.).
- Traits and concepts (Rust, modern C++) for fine-grained capability modeling.
The core need driving all of this was the same: don’t repeat algorithms, express constraints instead.
5.4. Why it’s still deeply relevant
Today, this pattern underpins:
- Standard libraries: collections, algorithms, iterators.
- Serialization frameworks: converting to/from JSON, binary, etc.
- Numeric and scientific computing: linear algebra over many numeric types.
- Asynchronous and reactive systems: futures, streams, and composable effects.
- Domain-specific libraries: anything that applies the same logic to many domain models.
As long as we need reusable algorithms over evolving data types, this pattern will remain central.
6. Worked examples and intuitive analogies
6.1. Universal remote control analogy
Think of a universal remote:
- The remote is a generic function/algorithm.
- Each device (TV, receiver, projector) is a concrete type with different hardware.
- The remote needs each device to implement a certain “protocol” (respond to volume, power, channel commands).
Once a device implements that protocol (via an IR code set, HDMI-CEC, etc.), the same remote can control it. You don’t create a new remote for every device.
6.2. A simple generic “toString” pipeline
Imagine you want a pipeline that takes a list of values of various types and logs them as strings. You want the logging logic to be generic, with each type responsible for describing itself.
// Pseudocode, language-agnostic
// Capability: anything that can describe itself as text
interface Describable {
toText(): String
}
// Generic logger: knows only how to call toText()
function logAll<T extends Describable>(values: List<T>) {
for value in values {
print(value.toText())
}
}
// Concrete types
class User implements Describable {
name: String
toText() = "User(" + name + ")"
}
class Order implements Describable {
id: Int
toText() = "Order#" + id.toString()
}
// Usage
logAll([ new User("Alice"), new User("Bob") ])
logAll([ new Order(101), new Order(102) ])
The logging algorithm is defined once. New types only need to implement
Describable to participate.
6.3. Numeric example: generic vector scaling
Suppose you want to scale a numeric vector by a scalar. One naive approach would be to write
separate functions for int, float, double, etc. A better
approach is to use a numeric capability.
// Trait describing types that support multiplication
trait Scalable {
fn mul(self, other: Self) -> Self;
}
fn scale_vector<T: Scalable + Copy>(v: &[T], factor: T) -> Vec<T> {
v.iter().map(|x| x.mul(factor)).collect()
}
// If we implement Scalable for i32, f64, etc.,
// scale_vector works automatically for all of them.
Again, one algorithm, many numeric types, and the compiler guarantees correctness of usage.
7. When and how to apply this pattern
7.1. When to generalize with type parameters and capabilities
It’s usually worth extracting a generic, capability-based function when:
- You find yourself writing the same algorithm for different types (sorting, mapping, formatting, serializing).
- The algorithm clearly depends on a small, stable set of operations (e.g., comparison, equality, hashing, addition).
- Those operations make sense to express as a capability contract.
7.2. When not to generalize
Over-generalization can make code harder to understand. Avoid forcing generics and traits / typeclasses when:
- The behavior you want is highly specific to one concrete type and unlikely to be reused.
- The capability surface is large and unstable; the abstraction will likely churn frequently.
- You don’t yet understand the problem well enough to define the right capability boundaries.
7.3. Good capability design
Good interfaces / traits / typeclasses usually:
- Capture a single, cohesive behavior (e.g., “can be iterated,” “can be hashed”).
- Contain a minimal set of operations required for a useful abstraction.
- Are stable over time, so downstream implementations don’t break with every change.
Comments
Post a Comment