Start learning free

LESSON 188 OF 348 · OBJECT-ORIENTED PROGRAMMING

Polymorphism in TypeScript: One Call, Many Behaviours

Lesson Read the full explanation

TL ; DR:

Polymorphism is when a single piece of code (like animal.speak()) does different things depending on which subclass the object actually is. You write the call once, and the right override runs automatically.


let animals = [new Dog(), new Cat()];
for (let a of animals) {
    a.speak();   // dispatches to Dog.speak then Cat.speak
}


Full lesson:

Polymorphism (Greek for "many forms") is what makes inheritance and interfaces actually useful at scale. Once Dog and Cat both inherit from Animal and override speak(), you can stop caring which one you're holding. Any code that takes an Animal and calls speak() works for both.

Why it matters

Imagine you're writing a function that prints what every animal in a zoo says:

void Roll(Animal[] animals) {
    foreach (var a in animals) a.Speak();
}

This function works for any current or future kind of animal. Add a Cow subclass tomorrow — this loop runs Cow.Speak() automatically, no edits needed. That's the win: code that works today and keeps working as the type hierarchy grows.

Static type vs dynamic type

The reference is typed as Animal. The object it actually points to is a Dog, or a Cat, etc. Polymorphism is the language dispatching the method call based on the dynamic type (what it is at runtime) rather than the static type (what the variable was declared as).

Notes by language

  • C# / C++ — the parent method must be virtual (and the override marked override) for runtime dispatch. Otherwise you get static dispatch and the wrong version runs.
  • Java / JS / Python — methods are virtual by default. Just override and it works.

Your task

Animal, Dog, and Cat are already defined, with the right overrides. Inside MainFunction, build a collection containing one Dog and one Cat (typed as Animal), then loop through it calling speak() on each.

Expected output:

Woof
Meow
STEP 1 OF 2
    Ctrl + Enter to check

    TYPESCRIPT · OBJECT-ORIENTED PROGRAMMING

    Polymorphism in TypeScript, explained

    What polymorphism is

    The word means "many forms". In object-oriented code it describes a single piece of code, such as a.speak(), that does different things depending on which kind of object a actually is. You write the call once, and the version that runs is chosen by the object. It builds directly on the last two lessons: inheritance gives Dog and Cat a common parent, and overriding gives each its own speak.

    The setup

    The starter code defines three classes. Animal is the parent, with a speak method that prints .... Dog and Cat extend it and override speak to print Woof and Meow. A Dog is an Animal, and so is a Cat, so TypeScript accepts either one wherever an Animal is expected.

    Static type and run-time class

    Two different ideas are at work, and keeping them apart is the key to this lesson.

    • The declared type is what the compiler knows: a variable typed Animal is only known to hold an animal. The compiler lets you call only what Animal offers, such as speak.
    • The actual class is what the object really is when the program runs: a Dog or a Cat.

    When you call a.speak(), JavaScript looks at the actual object and runs the speak that its class provides. Types are erased during compilation, so the declared type never takes part in that decision. This is why the call can print Woof for one animal and Meow for another.

    The exercise

    The exercise puts Dogs and Cats in one collection and treats them alike.

    1. Build the collection. Declare a constant animals with the type Animal[], an array of animals, and fill it with new Dog() and new Cat(). Each subclass object fits the parent type, so both are accepted. From now on the code using animals only knows that it holds animals.
    2. Loop and call. Use for (const a of animals) and call a.speak() in the body. Each iteration gets one element, and const is enough since the loop never reassigns a.
    class Animal {
        speak(): void {
            console.log("...");
        }
    }
    
    class Dog extends Animal {
        speak(): void {
            console.log("Woof");
        }
    }
    
    class Cat extends Animal {
        speak(): void {
            console.log("Meow");
        }
    }
    
    const animals: Animal[] = [new Dog(), new Cat()];
    for (const a of animals) {
        a.speak();
    }
    

    The output is:

    Woof
    Meow
    

    The loop contains no if asking whether an element is a dog or a cat. The dispatch is automatic.

    Why it matters

    Suppose you later add a Cow that extends Animal and prints Moo. The loop doesn't change at all. Pushing a Cow into animals makes the same lines print Moo for it. Code written against the parent keeps working as new subclasses appear, and the same holds for a function that takes an Animal[] parameter. A subclass that doesn't override speak falls back to the parent's version, so a class that does nothing but extend Animal prints ....

    Common mistakes

    • Leaving off the parent type. Without the annotation, TypeScript infers the element type from the contents, a type built from the subclasses such as Dog | Cat rather than Animal. It would still work, but the lesson is about the common parent type, so write Animal[].
    • Calling a child-only method on an Animal. If Dog had a method fetch(), the compiler would reject a.fetch() on an Animal, since not every animal can fetch. Only members of the declared type can be called.
    • Renaming the override. If Cat defined meow() instead of speak(), the cat would inherit Animal's speak and print ....
    • Reading too much into the type. TypeScript compares object types by their members. An unrelated class with a matching speak(): void method is accepted as an Animal too, and calling it works. Its object is not instanceof Animal, because it does not extend the class. Rely on a shared parent when you want that connection to be explicit.
    • Casting to make code compile. A cast such as as Animal changes only what the compiler believes. If the object has no speak, the call fails at run time with a TypeError.

    Key takeaways

    • Polymorphism lets one call, such as a.speak(), run different code for different objects.
    • A subclass object can be stored wherever the parent type is expected, for instance in an Animal[].
    • The method that runs is chosen by the object's actual class at run time, not by the declared type.
    • New subclasses work with existing code without any edits to it.
    Keep going with TypeScriptAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free