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

πŸ“… Published: April 18, 2025 ✏️ Updated: May 5, 2025 By Ojaswi Athghara
#polymorphism #web-development #overloading #overriding #oop

Polymorphism for Beginners: Method Overloading vs Overriding in TypeScript

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:

  1. Compile-time (Static): Method Overloading
  2. 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

AspectMethod OverloadingMethod Overriding
DefinitionMultiple signatures, same method nameChild redefines parent method
WhenCompile-time (static)Runtime (dynamic)
WhereSame classParent-child classes
ParametersMust differMust match parent
Return TypeCan differShould match parent
PurposeMultiple ways to callDifferent behavior for different types
KeywordNoneoverride (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

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Β© ojaswiat.com 2025-2027