Start learning free

LESSON 191 OF 348 · OBJECT-ORIENTED PROGRAMMING

OOP Capstone in TypeScript: An Animal Hierarchy

Lesson Read the full explanation

TL ; DR:

Put it all together: an abstract parent class, three concrete subclasses, and a polymorphic loop that dispatches by runtime type. This is the shape of nearly every real OOP design.


class Animal {
    constructor(name) { this.name = name; }
    sound() { throw new Error("Not implemented"); }
    speak() {                  // shared by all
        console.log(this.name + ": " + this.sound());
    }
}
// Dog, Cat, Bird each override sound
// let zoo = [new Dog("Rex"), ...];
// for (let a of zoo) a.speak();


Full lesson:

This problem stitches together everything you've learned in this chapter:

  1. Inheritance — three concrete classes share a common parent.
  2. Constructors — each subclass forwards a name to the parent.
  3. Abstract methods — the parent declares sound() with no body; each child must provide one.
  4. Shared behavior — the parent has a concrete speak() that calls sound(); every subclass inherits it for free.
  5. Polymorphism — a collection typed as Animal holds a Dog, a Cat, and a Bird; speak() dispatches to the right sound() at runtime.

The shape of this code is the shape of nearly every real OOP design you'll see — a small contract at the top (the abstract parent), concrete variants below, and the rest of the program written against the abstract type. New variants get added without touching the consumer code.


Your task

The abstract Animal class is already defined, with a concrete speak() that calls the abstract sound(). Add three subclasses — Dog (returns "Woof"), Cat (returns "Meow"), and Bird (returns "Tweet"). Then in MainFunction / main, build a polymorphic collection holding one of each (Rex, Whiskers, Tweety in that order) and loop through it calling speak().

Expected output:

Rex: Woof
Whiskers: Meow
Tweety: Tweet
STEP 1 OF 2
    Ctrl + Enter to check

    TYPESCRIPT · OBJECT-ORIENTED PROGRAMMING

    Oop Capstone in TypeScript, explained

    What the capstone brings together

    This exercise combines the ideas from the object-oriented chapter into one small program:

    • Inheritance. Dog, Cat and Bird all extend a common parent, Animal.
    • A constructor in the parent. Animal stores a name, and the subclasses reuse that constructor.
    • Overriding. The parent has a sound() method that is only a placeholder. Each subclass replaces it with its own version.
    • Shared behaviour. The parent's speak() calls sound() and prints the result. Every subclass inherits speak() for free.
    • Polymorphism. An array typed Animal[] holds a dog, a cat and a bird, and calling speak() on each one runs the right sound().

    The shape is common in real programs: a small contract at the top, concrete variants below, and the rest of the code written against the parent type. Adding a new animal later doesn't require touching the loop that uses them.

    The starting point

    The starter code gives you the parent class:

    class Animal {
        name: string;
        constructor(name: string) { this.name = name; }
        sound(): string { throw new Error("Not implemented"); }
        speak(): void {
            console.log(this.name + ": " + this.sound());
        }
    }

    Three things to read carefully. name: string; declares the field and its type, and the constructor assigns it. sound() is a placeholder that throws an error, so an animal that doesn't say what it sounds like fails loudly instead of printing something misleading. And speak() uses this.sound(): which version of sound runs depends on the actual object, not on the class where speak is written.

    Step by step

    1. Write the three subclasses. Each one uses extends Animal and overrides sound(): string to return its text: "Woof", "Meow" and "Tweet". The return type matches the parent's, and TypeScript checks that an override is compatible with the method it replaces.
    2. Skip the constructors. A subclass that defines no constructor receives the parent's, so new Dog("Rex") already stores the name. You would need a constructor and a call to super(...) only if a subclass took extra arguments.
    3. Build the array as const animals: Animal[] with a Dog named Rex, a Cat named Whiskers and a Bird named Tweety, in that order. The annotation says every element is treated as an Animal, and each subclass qualifies because it extends it.
    4. Loop with for...of, naming the variable a (declared with const), and call a.speak() in the body. The loop never asks which kind of animal it has. That is polymorphism.

    The solution in TypeScript

    class Animal {
        name: string;
        constructor(name: string) { this.name = name; }
        sound(): string { throw new Error("Not implemented"); }
        speak(): void {
            console.log(this.name + ": " + this.sound());
        }
    }
    
    class Dog extends Animal {
        sound(): string { return "Woof"; }
    }
    
    class Cat extends Animal {
        sound(): string { return "Meow"; }
    }
    
    class Bird extends Animal {
        sound(): string { return "Tweet"; }
    }
    
    const animals: Animal[] = [new Dog("Rex"), new Cat("Whiskers"), new Bird("Tweety")];
    for (const a of animals) {
        a.speak();
    }

    Output:

    Rex: Woof
    Whiskers: Meow
    Tweety: Tweet

    How the dispatch works

    On the first pass, a refers to the Dog. The call a.speak() finds no speak in Dog, so JavaScript looks in the parent and runs Animal.speak. Inside it, this is still the dog, so this.sound() finds Dog's own override first and returns "Woof". The same line of code in speak gives three different results because this is a different object each time.

    Here is what happens if a subclass forgets to override. The placeholder runs:

    class Fish extends Animal {}
    
    new Fish("Nemo").speak();

    This throws Error: Not implemented. The compiler accepts a Fish, because sound exists in the parent. If you want the compiler to reject it, you can mark the parent and its method abstract. Then every concrete subclass must define sound, and new Animal(...) is an error. The placeholder-and-throw style used here moves that check from compile time to run time.

    Types are also erased when the code is compiled, so the annotation Animal[] changes nothing about how the program runs. It only controls what the compiler lets you write. For example, if Dog had an extra fetch() method, a.fetch() would be a compile error inside the loop, because a is typed as Animal, even though the object does have the method.

    Common mistakes

    • Wrong order in the array. The output follows the array: Rex, Whiskers, Tweety.
    • Overriding with another return type. A sound() that returns a number is reported as not assignable to the parent's sound(): string.
    • Writing speak in each subclass. It works, but duplicates the shared code and loses the point of inheritance. Only sound varies.
    • Printing instead of returning in sound. The parent's speak builds the line itself, so sound must return the text only.
    • Using for...in. It would give array indexes as strings. Use for...of to get the elements.
    • Misspelling a name. The subclass method must be called exactly sound, or it won't override anything and the placeholder throws instead.

    Key takeaways

    • Put shared data and behaviour in the parent, and the parts that vary in overridable methods.
    • A subclass without its own constructor reuses the parent's.
    • Calls on this run the overriding method of the actual object, which is what makes one loop work for every kind of animal.
    • Code written against the parent type keeps working when new subclasses are added.
    Keep going with TypeScriptAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free