Disclaimer: This is my personal blog. The opinions expressed in this blogpost are my own & do not necessarily represent the opinions, beliefs or viewpoints of my current or any previous employers.
I have recently started looking into TypeScript. One of the concepts that puzzled me was the use of generics, particularly the keyof
and extends
keywords.
In this article, I'll break down these concepts, explain their differences, and illustrate how they can work together to create more robust and flexible code.
What Are Generics?
Generics in TypeScript allow you to create reusable components that can work with any data type. This is especially useful for functions, classes, and interfaces. Generics enable you to define a placeholder type that can be specified later, allowing for more type-safe code.
The keyof
Operator
The keyof
operator enables you to obtain the keys of an object type as a union of string literal types. This means you can create types that are constrained to the keys of an object, ensuring type safety when accessing properties.
Example of keyof
Let’s consider a simple interface:
interface Person {
name: string;
age: number;
}
Using keyof
, we can create a type that represents the keys of the Person
interface:
type PersonKeys = keyof Person; // "name" | "age"
This is particularly useful when you want to write a function that accesses properties dynamically:
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Example usage:
const person: Person = { name: "Alice", age: 30 };
const name = getValue(person, "name"); // Type is string
const age = getValue(person, "age"); // Type is number
In this example, getValue
ensures that the key
parameter is a valid key of the obj
, preventing potential runtime errors.
The extends
Keyword
On the other hand, the extends
keyword is used in generics to restrict the type that can be passed. This allows you to enforce certain structures or properties on the generic type.
Example of extends
The extends
keyword in TypeScript serves multiple purposes, primarily in generics. It allows you to constrain the types that can be passed into a generic function, class, or interface. This means you can specify that a type must satisfy certain conditions or implement certain properties.
Use Cases for extends
- Constraining Generic Types: This is the most common use of
extends
. It ensures that the type passed as a generic argument adheres to a specific structure or interface. - Conditional Types:
extends
can also be used in conditional types to create type mappings based on whether a type extends another type.
1. Constraining Generic Types
When defining a generic function or class, you can use extends
to restrict the types that can be used. Here's an example:
interface Animal {
name: string;
}
interface Dog extends Animal {
bark: () => void;
}
function makeSound<T extends Animal>(animal: T): void {
console.log(`This animal is named ${animal.name}`);
}
// Example usage:
const dog: Dog = {
name: "Buddy",
bark: () => console.log("Woof!"),
};
makeSound(dog); // Valid
// makeSound({ bark: () => {}, age: 5 }); // Error: Object is not assignable to parameter of type 'Animal'.
In this example, the makeSound
function only accepts types that extend the Animal
interface. This ensures that any object passed in will have at least a name
property.
2. Conditional Types
The extends
keyword can also be utilized in conditional types to create more complex type logic. This allows you to define types based on whether a certain condition is met.
Here’s an example:
type IsString<T> = T extends string ? "Yes" : "No";
// Example usage:
type Test1 = IsString<string>; // "Yes"
type Test2 = IsString<number>; // "No"
type Test3 = IsString<"Hello">; // "Yes"
In this case, the IsString
type checks if the given type T
extends string
. If it does, it resolves to "Yes"
; otherwise, it resolves to "No"
.
Combining keyof
and extends
You can also combine both keyof
and extends
to create even more powerful and type-safe functions.
Imagine you have an object representing a user profile, and you want to create a function that updates a specific property of this object. Using keyof
and extends
, you can ensure that the function only accepts valid keys of the object.
Step 1: Define the User Profile Interface
First, let’s define a simple interface for a user profile:
interface UserProfile {
name: string;
age: number;
email: string;
}
Now, we’ll create a function that allows updating a property of the UserProfile
. This function will use both keyof
and extends
to ensure type safety:
function updateProfile<T extends UserProfile, K extends keyof T>(profile: T, key: K, value: T[K]): T {
return { ...profile, [key]: value };
}
Explanation
<T extends UserProfile>
: This meansT
can be any type that extendsUserProfile
. This gives flexibility if you want to have additional properties in the user profile.<K extends keyof T>
: This ensures thatK
is a valid key ofT
. This means you can only pass a key that exists on theprofile
object.value: T[K]
: This specifies that the value must be of the same type as the property being updated.
Let’s see how this function works in practice:
const user: UserProfile = {
name: "Alice",
age: 30,
email: "alice@example.com",
};
// Updating the users age
const updatedUser = updateProfile(user, "age", 31);
console.log(updatedUser); // { name: "Alice", age: 31, email: "alice@example.com" }
// Updating the user's email
const updatedEmailUser = updateProfile(user, "email", "alice.new@example.com");
console.log(updatedEmailUser); // { name: "Alice", age: 30, email: "alice.new@example.com" }
// The following line would produce a TypeScript error, as "address" is not a key of UserProfile:
// const errorUser = updateProfile(user, "address", "123 Main St");
Feel free to share your thoughts or experiences with TypeScript in the comments below. Happy coding!