The Power of Inheritance and Interfaces for Building Object-Oriented Systems in TypeScript
- In object-oriented programming, inheritance allows you to create new classes (subclasses) that inherit properties and methods from existing classes (superclasses).
- The
extends
keyword establishes this relationship. - A subclass can access and use the inherited members (properties and methods) of the superclass.
- It's like a child inheriting traits from a parent.
Example:
class Vehicle {
color: string;
constructor(color: string) {
this.color = color;
}
move() {
console.log('The vehicle is moving.');
}
}
class Car extends Vehicle { // Car inherits from Vehicle
wheels: number;
constructor(color: string, wheels: number) {
super(color); // Call the superclass constructor to initialize color
this.wheels = wheels;
}
startEngine() {
console.log('The car engine started.');
}
}
const myCar = new Car('red', 4);
myCar.move(); // Inherited from Vehicle
myCar.startEngine(); // Specific to Car
Interfaces (implements):
- Interfaces define contracts that classes must adhere to. They specify the structure (properties and methods) that a class must implement, but they don't provide the actual implementation.
- The
implements
keyword is used to declare that a class implements an interface. - This ensures that the class provides the required functionality defined in the interface.
- It's like a blueprint or a set of rules that a class must follow.
interface Flyable {
fly(): void;
}
class Bird implements Flyable { // Bird implements the Flyable interface
fly() {
console.log('The bird is flying.');
}
}
class Airplane implements Flyable { // Airplane also implements Flyable
fly() {
console.log('The airplane is flying.');
}
}
const sparrow = new Bird();
sparrow.fly();
const boeing = new Airplane();
boeing.fly();
Key Differences:
extends
creates a parent-child (is-a) relationship, whileimplements
defines a contractual obligation (can-do).- A class can only inherit from one superclass, but it can implement multiple interfaces.
- Inherited members can be overridden in subclasses, while interfaces enforce a mandatory implementation.
Choosing between extends
and implements
:
- Use
extends
when you have a class hierarchy and want to specialize subclasses with additional properties and methods. - Use
implements
when you want a class to provide functionality defined in an interface, regardless of its inheritance structure.
// Base class (superclass)
class Vehicle {
color: string;
constructor(color: string) {
this.color = color;
}
move() {
console.log('The vehicle is moving.');
}
}
// Subclass (derived class) inheriting from Vehicle
class Car extends Vehicle {
wheels: number;
constructor(color: string, wheels: number) {
super(color); // Call the superclass constructor to initialize color
this.wheels = wheels;
}
startEngine() {
console.log('The car engine started.');
}
}
// Create a Car object
const myCar = new Car('red', 4);
// Access inherited and specific methods
myCar.move(); // Inherited from Vehicle
myCar.startEngine(); // Specific to Car
// Interface defining required functionality (contract)
interface Flyable {
fly(): void;
}
// Class implementing Flyable interface
class Bird implements Flyable {
fly() {
console.log('The bird is flying.');
}
}
// Another class implementing the same interface
class Airplane implements Flyable {
fly() {
console.log('The airplane is flying.');
}
}
// Create Bird and Airplane objects
const sparrow = new Bird();
sparrow.fly();
const boeing = new Airplane();
boeing.fly();
-
Composition:
- Instead of inheriting from a class, you can create a separate object (often called a mixin) that encapsulates common functionality.
- Your main class can then hold a reference to this mixin and access its methods.
- This approach provides more flexibility and avoids tight coupling between classes.
// Mixin containing common functionality class VehicleMixin { color: string; constructor(color: string) { this.color = color; } move() { console.log('The vehicle is moving.'); } } // Main class with a reference to the mixin class Car { wheels: number; vehicleMixin: VehicleMixin; constructor(color: string, wheels: number) { this.wheels = wheels; this.vehicleMixin = new VehicleMixin(color); // Create and assign mixin } startEngine() { console.log('The car engine started.'); } } const myCar = new Car('red', 4); myCar.move(); // Accessed from the mixin myCar.startEngine(); // Specific to Car
-
Dependency Injection:
- You can inject dependencies as arguments into your class constructor.
- These dependencies can provide the desired functionality, similar to inherited methods.
- This approach promotes loose coupling and easier testing.
// Interface defining the required functionality interface Movable { move(): void; } // Class implementing the interface class Engine implements Movable { move() { console.log('The engine is providing power for movement.'); } } // Main class with a dependency class Car { wheels: number; engine: Movable; constructor(wheels: number, engine: Movable) { this.wheels = wheels; this.engine = engine; // Inject engine dependency } startEngine() { console.log('The car engine started.'); } drive() { this.engine.move(); // Use injected dependency } } const myEngine = new Engine(); const myCar = new Car(4, myEngine); myCar.startEngine(); myCar.drive(); // Calls move() from the injected engine
-
Type Assertions:
- If you're absolutely certain a variable holds an object with the desired structure, you can use type assertions to tell TypeScript to treat it as having specific properties and methods.
- However, this approach bypasses type checking and can lead to runtime errors if the assertion is wrong. Use it cautiously.
// Function expecting an object with a "fly" method function makeFly(flyer: { fly(): void }) { flyer.fly(); } const bird = { fly() { console.log('The bird is flying.'); } }; const airplane = { soar() { console.log('The airplane is soaring.'); } }; makeFly(bird); // Works as expected // Potential runtime error if airplane doesn't have "fly" makeFly(airplane as { fly(): void }); // Type assertion (use with caution)
-
Type Guards:
- Type guards are functions that help narrow down the type of a variable based on runtime checks.
- You can use type guards to ensure a variable has the required properties before using them.
- This provides better type safety compared to type assertions.
// Function expecting an object with a "fly" method function makeFly(flyer: unknown) { if (typeof flyer.fly === 'function') { flyer.fly(); } else { console.error('Object does not have a fly method.'); } } const bird = { fly() { console.log('The bird is flying.'); } }; const airplane = { soar() { console.log('The airplane is soaring.'); } }; makeFly(bird); // Works as expected makeFly(airplane); // Catches the error without runtime issues
typescript extends implements