Polymorphism for Beginners: Method Overloading vs Overriding in TypeScript
Master polymorphism in TypeScript with clear examples of method overloading and overriding. Learn compile-time vs runtime polymorphism, function signatures, and practical design patterns

Two Methods, Same Name β How Does TypeScript Know Which One to Call?
Six months into TypeScript, I encountered this code:
class Logger {
log(message: string): void { }
log(code: number, message: string): void { }
}
"Wait," I thought, "two methods with the same name? Won't TypeScript complain?"
It didn't. It worked perfectly.
Then I saw this:
class Animal {
makeSound(): string { return "Some sound"; }
}
class Dog extends Animal {
makeSound(): string { return "Woof!"; } // Same method name!
}
"Two makeSound() methods? How does it know which one to call?"
That's when my mentor introduced me to polymorphismβspecifically method overloading and method overriding. Understanding the difference changed how I designed entire systems.
Today, I'll show you both concepts with practical TypeScript examples that'll make it click instantly.
What Is Polymorphism?
Polymorphism means "many forms." In programming, it allows objects to take multiple forms:
- Same method name, different implementations
- Same interface, different behaviors
- One call, multiple possible outcomes
Two types of polymorphism:
- Compile-time (Static): Method Overloading
- Runtime (Dynamic): Method Overriding
Let's explore both!
Method Overloading: Compile-Time Polymorphism
Method overloading means having multiple method signatures with the same name but different parameters.
Basic Method Overloading
class Calculator {
// Method signature 1: Add two numbers
add(a: number, b: number): number;
// Method signature 2: Add three numbers
add(a: number, b: number, c: number): number;
// Method signature 3: Add array of numbers
add(numbers: number[]): number;
// Implementation (handles all cases)
add(a: number | number[], b?: number, c?: number): number {
if (Array.isArray(a)) {
// Case 3: Array
return a.reduce((sum, num) => sum + num, 0);
} else if (c !== undefined) {
// Case 2: Three numbers
return a + b! + c;
} else {
// Case 1: Two numbers
return a + b!;
}
}
}
const calc = new Calculator();
console.log(calc.add(5, 3)); // 8 (two numbers)
console.log(calc.add(5, 3, 2)); // 10 (three numbers)
console.log(calc.add([1, 2, 3, 4, 5])); // 15 (array)
How it works:
- TypeScript sees three signatures for
add() - At compile time, it checks which signature you're using
- The implementation handles all cases
- Type safety ensures you can't call with wrong arguments
Real-World Example: Logger with Overloading
class Logger {
// Overload 1: Log simple message
log(message: string): void;
// Overload 2: Log with level
log(level: string, message: string): void;
// Overload 3: Log with level and metadata
log(level: string, message: string, metadata: object): void;
// Implementation
log(
levelOrMessage: string,
message?: string,
metadata?: object
): void {
const timestamp = new Date().toISOString();
if (message === undefined) {
// Overload 1: Simple message
console.log(`[${timestamp}] ${levelOrMessage}`);
} else if (metadata === undefined) {
// Overload 2: Level + message
console.log(`[${timestamp}] [${levelOrMessage}] ${message}`);
} else {
// Overload 3: Level + message + metadata
console.log(`[${timestamp}] [${levelOrMessage}] ${message}`, metadata);
}
}
}
const logger = new Logger();
// Different ways to call the same method!
logger.log("Application started");
// [2025-10-27T10:30:00.000Z] Application started
logger.log("INFO", "User logged in");
// [2025-10-27T10:30:00.000Z] [INFO] User logged in
logger.log("ERROR", "Database connection failed", {
host: "localhost",
port: 5432
});
// [2025-10-27T10:30:00.000Z] [ERROR] Database connection failed { host: 'localhost', port: 5432 }
Method Overloading with Different Return Types
class DataFetcher {
// Overload 1: Fetch single user by ID
fetch(id: number): Promise<User>;
// Overload 2: Fetch multiple users by IDs
fetch(ids: number[]): Promise<User[]>;
// Overload 3: Fetch with options
fetch(options: { limit: number; offset: number }): Promise<User[]>;
// Implementation
async fetch(
param: number | number[] | { limit: number; offset: number }
): Promise<User | User[]> {
if (typeof param === 'number') {
// Single user
return await this.fetchUserById(param);
} else if (Array.isArray(param)) {
// Multiple users by IDs
return await this.fetchUsersByIds(param);
} else {
// Fetch with pagination
return await this.fetchUsersWithOptions(param);
}
}
private async fetchUserById(id: number): Promise<User> {
// Simulate API call
return { id, name: `User ${id}`, email: `user${id}@example.com` };
}
private async fetchUsersByIds(ids: number[]): Promise<User[]> {
// Simulate API call
return ids.map(id => ({
id,
name: `User ${id}`,
email: `user${id}@example.com`
}));
}
private async fetchUsersWithOptions(
options: { limit: number; offset: number }
): Promise<User[]> {
// Simulate API call with pagination
const users: User[] = [];
for (let i = options.offset; i < options.offset + options.limit; i++) {
users.push({ id: i, name: `User ${i}`, email: `user${i}@example.com` });
}
return users;
}
}
interface User {
id: number;
name: string;
email: string;
}
const fetcher = new DataFetcher();
// Different call patterns, same method name!
const user = await fetcher.fetch(1); // Returns User
const users = await fetcher.fetch([1, 2, 3]); // Returns User[]
const pagedUsers = await fetcher.fetch({ limit: 10, offset: 0 }); // Returns User[]
Method Overriding: Runtime Polymorphism
Method overriding means a child class provides a different implementation of a method that's already defined in the parent class.
Basic Method Overriding
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
makeSound(): string {
return "Some generic animal sound";
}
introduce(): string {
return `I'm ${this.name} and I say: ${this.makeSound()}`;
}
}
class Dog extends Animal {
// Override makeSound() method
makeSound(): string {
return "Woof! Woof!";
}
}
class Cat extends Animal {
// Override makeSound() method
makeSound(): string {
return "Meow! Meow!";
}
}
class Cow extends Animal {
// Override makeSound() method
makeSound(): string {
return "Moo! Moo!";
}
}
// Polymorphism in action!
const animals: Animal[] = [
new Dog("Buddy"),
new Cat("Whiskers"),
new Cow("Bessie")
];
// Same method call, different outputs!
animals.forEach(animal => {
console.log(animal.introduce());
});
// Output:
// I'm Buddy and I say: Woof! Woof!
// I'm Whiskers and I say: Meow! Meow!
// I'm Bessie and I say: Moo! Moo!
Key point: The method called is determined at runtime based on the actual object type, not the variable type.
Real-World Example: Payment Processing
abstract class PaymentMethod {
constructor(public accountHolder: string) {}
// Abstract method (must be overridden)
abstract processPayment(amount: number): Promise<boolean>;
// Abstract method
abstract getPaymentDetails(): string;
// Concrete method (can be overridden or used as-is)
validateAmount(amount: number): boolean {
return amount > 0 && Number.isFinite(amount);
}
// Template method pattern
async executePayment(amount: number): Promise<void> {
if (!this.validateAmount(amount)) {
throw new Error("Invalid payment amount");
}
console.log(`\n${"=".repeat(50)}`);
console.log(`Processing payment for ${this.accountHolder}`);
console.log(`Amount: $${amount.toFixed(2)}`);
console.log(`Method: ${this.getPaymentDetails()}`);
const success = await this.processPayment(amount);
if (success) {
console.log("β
Payment successful!");
} else {
console.log("β Payment failed!");
}
console.log(${"=".repeat(50)});
}
}
class CreditCardPayment extends PaymentMethod {
constructor(
accountHolder: string,
private cardNumber: string,
private expiryDate: string,
private cvv: string
) {
super(accountHolder);
}
// Override abstract method
async processPayment(amount: number): Promise<boolean> {
console.log("Processing credit card payment...");
console.log(`Card: ****${this.cardNumber.slice(-4)}`);
// Simulate payment processing
await new Promise(resolve => setTimeout(resolve, 1000));
return true; // Simulate success
}
// Override abstract method
getPaymentDetails(): string {
return `Credit Card (****${this.cardNumber.slice(-4)})`;
}
}
class PayPalPayment extends PaymentMethod {
constructor(
accountHolder: string,
private email: string
) {
super(accountHolder);
}
// Override abstract method
async processPayment(amount: number): Promise<boolean> {
console.log("Processing PayPal payment...");
console.log(`PayPal Account: ${this.email}`);
// Simulate payment processing
await new Promise(resolve => setTimeout(resolve, 1500));
return true; // Simulate success
}
// Override abstract method
getPaymentDetails(): string {
return `PayPal (${this.email})`;
}
}
class CryptoPayment extends PaymentMethod {
constructor(
accountHolder: string,
private walletAddress: string,
private coinType: string = "Bitcoin"
) {
super(accountHolder);
}
// Override abstract method
async processPayment(amount: number): Promise<boolean> {
console.log(`Processing ${this.coinType} payment...`);
console.log(`Wallet: ${this.walletAddress.slice(0, 8)}...${this.walletAddress.slice(-8)}`);
// Simulate blockchain confirmation
await new Promise(resolve => setTimeout(resolve, 2000));
return true; // Simulate success
}
// Override abstract method
getPaymentDetails(): string {
return `${this.coinType} Wallet`;
}
// Override concrete method to add crypto-specific validation
override validateAmount(amount: number): boolean {
const baseValidation = super.validateAmount(amount);
// Crypto minimum: $10
return baseValidation && amount >= 10;
}
}
// Usage: Polymorphism in action!
async function processOrder(paymentMethod: PaymentMethod, amount: number) {
await paymentMethod.executePayment(amount);
}
const creditCard = new CreditCardPayment(
"Alice Johnson",
"4532123456789012",
"12/26",
"123"
);
const paypal = new PayPalPayment(
"Bob Smith",
"bob.smith@email.com"
);
const crypto = new CryptoPayment(
"Charlie Brown",
"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
"Bitcoin"
);
// Same function, different behaviors!
await processOrder(creditCard, 99.99);
await processOrder(paypal, 149.99);
await processOrder(crypto, 299.99);
Using super to Call Parent Method
class Employee {
constructor(
public name: string,
public baseSalary: number
) {}
getSalary(): number {
return this.baseSalary;
}
getDetails(): string {
return `Employee: ${this.name}, Salary: $${this.getSalary()}`;
}
}
class Manager extends Employee {
constructor(
name: string,
baseSalary: number,
private bonus: number
) {
super(name, baseSalary);
}
// Override to add bonus
override getSalary(): number {
const base = super.getSalary(); // Call parent method
return base + this.bonus;
}
// Override to add manager-specific info
override getDetails(): string {
const baseDetails = super.getDetails(); // Call parent method
return `${baseDetails}, Bonus: $${this.bonus}`;
}
}
class Developer extends Employee {
constructor(
name: string,
baseSalary: number,
private stockOptions: number
) {
super(name, baseSalary);
}
// Override to add stock options value
override getSalary(): number {
const base = super.getSalary();
const stockValue = this.stockOptions * 50; // $50 per option
return base + stockValue;
}
// Override with developer-specific info
override getDetails(): string {
const baseDetails = super.getDetails();
return `${baseDetails}, Stock Options: ${this.stockOptions}`;
}
}
const emp = new Employee("John", 60000);
const mgr = new Manager("Alice", 80000, 20000);
const dev = new Developer("Bob", 90000, 1000);
console.log(emp.getDetails());
// Employee: John, Salary: $60000
console.log(mgr.getDetails());
// Employee: Alice, Salary: $100000, Bonus: $20000
console.log(dev.getDetails());
// Employee: Bob, Salary: $140000, Stock Options: 1000
Overloading vs Overriding: Side-by-Side Comparison
// ============================================
// METHOD OVERLOADING (Compile-time)
// ============================================
class MathOperations {
// Same method name, different parameters
multiply(a: number, b: number): number;
multiply(a: number, b: number, c: number): number;
multiply(a: string, times: number): string;
multiply(a: number | string, b: number, c?: number): number | string {
if (typeof a === 'string') {
return a.repeat(b);
} else if (c !== undefined) {
return a * b * c;
} else {
return a * b;
}
}
}
const math = new MathOperations();
console.log(math.multiply(5, 3)); // 15
console.log(math.multiply(5, 3, 2)); // 30
console.log(math.multiply("Hi", 3)); // "HiHiHi"
// ============================================
// METHOD OVERRIDING (Runtime)
// ============================================
class Shape {
getArea(): number {
return 0; // Default implementation
}
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super();
}
// Override parent method
override getArea(): number {
return this.width * this.height;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
// Override parent method (different implementation!)
override getArea(): number {
return Math.PI * this.radius ** 2;
}
}
const shapes: Shape[] = [
new Rectangle(5, 10),
new Circle(7)
];
// Same method call, different calculations!
shapes.forEach(shape => {
console.log(`Area: ${shape.getArea().toFixed(2)}`);
});
// Area: 50.00 (Rectangle)
// Area: 153.94 (Circle)
Key Differences: Overloading vs Overriding
| Aspect | Method Overloading | Method Overriding |
|---|---|---|
| Definition | Multiple signatures, same method name | Child redefines parent method |
| When | Compile-time (static) | Runtime (dynamic) |
| Where | Same class | Parent-child classes |
| Parameters | Must differ | Must match parent |
| Return Type | Can differ | Should match parent |
| Purpose | Multiple ways to call | Different behavior for different types |
| Keyword | None | override (optional but recommended) |
Advanced: Combining Overloading and Overriding
class Notifier {
// Method overloading
send(message: string): void;
send(message: string, priority: "low" | "high"): void;
send(message: string, priority?: "low" | "high"): void {
const level = priority || "low";
console.log(`[${level.toUpperCase()}] ${message}`);
}
}
class EmailNotifier extends Notifier {
constructor(private email: string) {
super();
}
// Override with overloading!
override send(message: string): void;
override send(message: string, priority: "low" | "high"): void;
override send(message: string, priority?: "low" | "high"): void {
const level = priority || "low";
console.log(`π§ Sending email to ${this.email}`);
console.log(` [${level.toUpperCase()}] ${message}`);
}
}
class SMSNotifier extends Notifier {
constructor(private phoneNumber: string) {
super();
}
// Override with overloading!
override send(message: string): void;
override send(message: string, priority: "low" | "high"): void;
override send(message: string, priority?: "low" | "high"): void {
const level = priority || "low";
// SMS messages are short, so truncate
const shortMessage = message.substring(0, 160);
console.log(`π± Sending SMS to ${this.phoneNumber}`);
console.log(` [${level.toUpperCase()}] ${shortMessage}`);
}
}
const notifiers: Notifier[] = [
new EmailNotifier("alice@example.com"),
new SMSNotifier("+1-555-1234")
];
// Polymorphism + Overloading!
notifiers.forEach(notifier => {
notifier.send("System maintenance scheduled");
notifier.send("Critical: Server down!", "high");
});
Real-World Example: Shape Drawing System
interface Point {
x: number;
y: number;
}
abstract class Shape {
constructor(protected color: string) {}
// Abstract method (must override)
abstract draw(): void;
// Abstract method
abstract calculateArea(): number;
// Concrete method
getColor(): string {
return this.color;
}
// Concrete method that uses abstract methods
describe(): string {
return `${this.constructor.name} - Color: ${this.color}, Area: ${this.calculateArea().toFixed(2)}`;
}
}
class Rectangle extends Shape {
constructor(
color: string,
private width: number,
private height: number
) {
super(color);
}
override draw(): void {
console.log(`Drawing ${this.color} rectangle: ${this.width}x${this.height}`);
console.log("β" + "β".repeat(this.width) + "β");
for (let i = 0; i < this.height; i++) {
console.log("β" + " ".repeat(this.width) + "β");
}
console.log("β" + "β".repeat(this.width) + "β");
}
override calculateArea(): number {
return this.width * this.height;
}
}
class Circle extends Shape {
constructor(
color: string,
private radius: number
) {
super(color);
}
override draw(): void {
console.log(`Drawing ${this.color} circle with radius ${this.radius}`);
console.log(" β―");
}
override calculateArea(): number {
return Math.PI * this.radius ** 2;
}
}
class Triangle extends Shape {
constructor(
color: string,
private base: number,
private height: number
) {
super(color);
}
override draw(): void {
console.log(`Drawing ${this.color} triangle: base=${this.base}, height=${this.height}`);
console.log(" β³");
}
override calculateArea(): number {
return (this.base * this.height) / 2;
}
}
// Canvas that works with any shape!
class Canvas {
private shapes: Shape[] = [];
addShape(shape: Shape): void {
this.shapes.push(shape);
console.log(`β
Added ${shape.describe()}`);
}
drawAll(): void {
console.log("\n" + "=".repeat(50));
console.log("DRAWING ALL SHAPES");
console.log("=".repeat(50));
this.shapes.forEach((shape, index) => {
console.log(`\nShape ${index + 1}:`);
shape.draw();
});
}
getTotalArea(): number {
return this.shapes.reduce((total, shape) => {
return total + shape.calculateArea();
}, 0);
}
}
// Usage
const canvas = new Canvas();
canvas.addShape(new Rectangle("red", 10, 5));
canvas.addShape(new Circle("blue", 7));
canvas.addShape(new Triangle("green", 8, 6));
canvas.drawAll();
console.log(`\nTotal area of all shapes: ${canvas.getTotalArea().toFixed(2)}`);
Best Practices
1. Use override Keyword
class Parent {
method(): void {}
}
class Child extends Parent {
// Good: Explicit override
override method(): void {}
// Catches typos!
// override methd(): void {} // Error: Method doesn't exist in parent
}
2. Keep Overload Signatures Clear
// Good: Clear, distinct signatures
class API {
fetch(id: number): Promise<User>;
fetch(ids: number[]): Promise<User[]>;
fetch(options: FetchOptions): Promise<User[]>;
fetch(param: number | number[] | FetchOptions): Promise<User | User[]> {
// Implementation
}
}
// Bad: Ambiguous signatures
class API {
fetch(param: any): Promise<any> { // Too vague!
// Implementation
}
}
3. Use Abstract Classes for Common Interface
abstract class DataSource {
abstract connect(): Promise<void>;
abstract query(sql: string): Promise<any[]>;
abstract disconnect(): Promise<void>;
}
class MySQLDataSource extends DataSource {
override async connect(): Promise<void> { /* MySQL specific */ }
override async query(sql: string): Promise<any[]> { /* MySQL specific */ }
override async disconnect(): Promise<void> { /* MySQL specific */ }
}
class PostgreSQLDataSource extends DataSource {
override async connect(): Promise<void> { /* PostgreSQL specific */ }
override async query(sql: string): Promise<any[]> { /* PostgreSQL specific */ }
override async disconnect(): Promise<void> { /* PostgreSQL specific */ }
}
Conclusion: The Power of Polymorphism
Method Overloading gives you flexibility:
- Multiple ways to call the same method
- Type-safe different parameter combinations
- Cleaner API design
Method Overriding gives you extensibility:
- Specialize behavior in child classes
- Same interface, different implementations
- Loosely coupled, highly maintainable code
Together, they're unstoppable:
- Write flexible, type-safe APIs
- Build extensible class hierarchies
- Create clean, maintainable systems
Start using polymorphism in your TypeScript projects today. Your code will be more flexible, more maintainable, and more elegant!
If you're building amazing TypeScript applications with polymorphism, I'd love to hear about them! Connect with me on Twitter or LinkedIn!
Support My Work
If this guide helped you master polymorphism in TypeScript and understand the difference between overloading and overriding, I'd really appreciate your support, I'd really appreciate your support! Creating comprehensive, free content like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for developers.
β Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!
Cover image by Esther Jiao on Unsplash