The extends
keyword in TypeScript is a Swiss Army knife of sorts. It's used in multiple contexts, including inheritance, generics, and conditional types. Understanding how to use extends
effectively can lead to more robust, reusable, and type-safe code.
Inheritance using extends
One of the primary uses of extends
is in inheritance
, allowing you to create new interfaces or classes that build upon existing ones.
interface User {
firstName: string;
lastName: string;
email: string;
}
interface StaffUser extends User {
roles: string[];
department: string;
}
const regularUser: User = {
firstName: "John",
lastName: "Doe",
email: "john@example.com"
};
const staffMember: StaffUser = {
firstName: "Jane",
lastName: "Smith",
email: "jane@company.com",
roles: ["Manager", "Developer"],
department: "Engineering"
};
In this example, StaffUser
extends User
, inheriting all its properties and adding new ones. This allows us to create more specific types based on more general ones.
Class Inheritance
The extends
keyword is also used for class inheritance:
class Animal {
constructor(public name: string) {}
makeSound(): void {
console.log("Some generic animal sound");
}
}
class Dog extends Animal {
constructor(name: string, public breed: string) {
super(name);
}
makeSound(): void {
console.log("Woof! Woof!");
}
fetch(): void {
console.log(`${this.name} is fetching the ball!`);
}
}
const myDog = new Dog("Buddy", "Golden Retriever");
myDog.makeSound(); // Output: Woof! Woof!
myDog.fetch(); // Output: Buddy is fetching the ball!
Here, Dog
extends Animal
, inheriting its properties and methods, and also adding its own.
Type Constraints in Generics
The extends
keyword is crucial when working with generics, allowing us to constrain the types that can be used with a generic function or class.
interface Printable {
print(): void;
}
function printObject<T extends Printable>(obj: T) {
obj.print();
}
class Book implements Printable {
print() {
console.log("Printing a book.");
}
}
class Magazine implements Printable {
print() {
console.log("Printing a magazine.");
}
}
const myBook = new Book();
const myMagazine = new Magazine();
printObject(myBook); // Output: Printing a book.
printObject(myMagazine); // Output: Printing a magazine.
// printObject(42); // Error, number doesn't have a 'print' method
-
interface Printable
: Here, we define an interface namedPrintable
. This interface declares a contract that any class implementing it must adhere to. Tha contract specifies that any class implementingPrintable
must provide a method namedprint
that takes no arguments and returnsvoid
-
function printObject<T extends Printable>(obj: T)
: This is a generic function namedprintObject
. It takes a single argument namedobj
, which is typeT
. The type parameterT
is constrained to types that extend (implement) thePrintable
interface can bef used as the argument to this function. -
class Book implements Printable
andclass Magazine implements Printable
: Here, we define two classes,Book
andMagazine
, both of which implement thePrintable
interface. This means that these classes must provide aprint
method as required by the contract of thePrintable
interface. -
const myBook = new Book();
andconst myMagazine = new Magazine();
: We create instances of theBook
andMagazine
classes. -
printObject(myBook);
andprintObject(myMagazine);
: We call theprintObject
function with the instances ofBook
andMagazine
. Since bothBook
andMagazine
classes implement thePrintable
interface, they fulfill the constraint of theT extends Printable
type parameter. Inside the function, theprint
method of the appropriate class is called, resulting in the expected output. -
// printObject(42);
: If we try to callprintObject
with a type that doesn't implement thePrintable
interface, such as the number42
, TypeScript will raise an error. This is because the type constraint is not satisfied, asnumber
doesn't have aprint
method as required by thePrintable
interface.
In summary, the extends
keyword in the context of function printObject<T extends Printable>(obj: T)
is used to ensure that the type T
used as the argument adheres to the contract defined by the Printable
interface. This ensures that only types with a print
method can be used with the printObject
function, enforcing a specific behavior and contract for the function's usage.
Conditional Types
T extends U ? X : Y
-
T
is the type that being checked -
U
is the condition type thatT
is being checked against. -
X
is the type that the conditional type evaluates to ifT
extends (is assignable to)U
-
Y
is the type that the conditional type evaluates to ifT
does not extendU
type ExtractNumber<T> = T extends number ? T : never;
type NumberOrNever = ExtractNumber<number>; // number
type StringOrNever = ExtractNumber<string>; // never
Here, the ExtractNumber
type takes a type parameter T
. The conditional type checks whether T
extends the number
type. if does, the type resolves to T
(which is number type). If it doesn't, the type resolves to never
.
The extends
Keyword with Union Types
Now, let's consider the expression A | B | C extends A
. This might seem counterintuitive at first, but in TypeScript, this condition is actually false
. Here's why:
- In TypeScript, when you use
extends
with a union type on the left side, it's equivalent to asking: "Is every possible type in this union assignable to the type on the right?" - In other words,
A | B | C extends A
is asking: "Can A be assigned to A, AND can B be assigned to A, AND can C be assigned to A?" - While
A
can certainly be assigned toA
,B
andC
might not be assignable toA
(unless they are subtypes ofA
), so the overall result isfalse
.
type Fruit = "apple" | "banana" | "cherry";
type CitrusFruit = "lemon" | "orange";
type IsCitrus<T> = T extends CitrusFruit ? true : false;
type Test1 = IsCitrus<"lemon">; // true
type Test2 = IsCitrus<"apple">; // false
type Test3 = IsCitrus<Fruit>; // false
In this example, IsCitrus<Fruit>
is false
because not all fruits
in the Fruit
union are CitrusFruit
.
Best Practices and Tips
-
Use
extends
for meaningful relationships: Only use inheritance when there's a clear "is-a" relationship between types. - Prefer composition over inheritance: In many cases, composition (using interfaces and type intersections) can be more flexible than class inheritance.
- Be cautious with deep inheritance chains: Deep inheritance can make code harder to understand and maintain.
- Leverage conditional types for flexible APIs: Use conditional types with extends to create APIs that adapt based on input types.
- Use extends in generics to create reusable, type-safe functions: This allows you to write functions that work with a variety of types while still maintaining type safety
Top comments (6)
I still fail to understand if there is a real difference between extending interfaces and using intersection types. Can anyone provide a brief explanation and example. Or is it more just a matter of personal style?
Honestly it's mostly preference
Interfaces do have a looser extension method, which is useful if you're releasing your modules as importable modules as it makes the consumer have an easier time extending your types
But ultimately it's largely preference, IMO
though I'm also not 100% up to speed on typescript best practice, so this may be incorrect
I do love Typescript's flexibility when it comes to typing and inheritance, and I'll definitely be keeping this article as a reference
Thank You
Thanks for sharing with the community, keep up the good work
Thank You