Previously, we’ve discussed the basics of TypeScript Generics. This time, we take them to a higher level. In this article, we learn about index types. To do so, we also explore union types, the keyof keyword, and string literal types. Today we also learn mapped types and use them with conditional types.
Index types
Generics are very useful in many situations. By using index types, we can improve our types even further. To get to know them, let’s first learn what a union type and the keyof keyword are.
A union type represents one of several types. To separate them, we use a vertical bar.
1 |
const value: string | number; |
String literal types are often used with unions. A string literal can only be assigned a particular string value. It can be considered a subtype of a string type.
1 |
type UserRole = 'admin' | 'moderator' | 'author'; |
Now, let’s consider this simple interface:
1 2 3 4 5 6 |
interface User { id: number; name: string; email: string; role: UserRole; } |
By using the keyof keyword, we can achieve a union of string literal types.
1 |
type UserKeysType = keyof User; // 'id' | 'name' | 'email' | 'role' ; |
All of the above knowledge gives us quite a bit of flexibility.
Introducing index types
TypeScript 2.1 introduced index types. They look the same as accessing a property of an object but refer to types.
1 |
type IdType = User['id']; // number |
We sometimes refer to index types as lookup types.
We can use the above in a dynamic manner. Let’s inspect this popular example:
1 2 3 4 5 6 |
const user: User = { id: 15, name: 'John', email: 'john@smith.com', role: 'admin' }; |
1 2 3 4 5 6 |
function getProperty<ObjectType, KeyType extends keyof ObjectType>( object: ObjectType, property: KeyType ): ObjectType[KeyType] { return object[property]; } |
When we use our getProperty, the compiler checks if a string that we pass into it is an actual property of an object.
1 |
getProperty(user, 'property'); |
Argument of type ‘”property”‘ is not assignable to parameter of type ‘”id” | “name” | “email” | “role”‘.
When we return object[property], the TypeScript compiler performs a type lookup. Thanks to that, the return type of the getProperty varies based on the passed string.
1 |
getProperty(user, 'id').toLowerCase(); |
Property ‘toLowerCase’ does not exist on type ‘number’.
Creating a Map from an object
A real-life example of the above might be converting an object to a Map. TypeScript aside, the most straightforward way to do this is to use Object.entries.
1 2 3 4 5 6 7 8 9 |
const settings = { isModalOpened: true, canDelete: false, role: 'Admin' } const settingsMap = new Map( Object.entries(settings) ); |
The above code, even though valid, does not produce the most detailed types.
1 |
settingsMap.get('role').toLowerCase() |
Property ‘toLowerCase’ does not exist on type ‘string | boolean’.
The error above indicates that the return type of the settingsMap.get function is a union type 'string | boolean'. We know that the type of role is a string. Let’s fix that!
We can create our own interface that extends Map and provides more detailed typings.
1 2 3 |
interface MapFromObject<ObjectType, KeyType extends keyof ObjectType> extends Map<KeyType, ObjectType[KeyType]> { get: <PropertyType extends keyof ObjectType>(key: PropertyType) => ObjectType[PropertyType]; } |
1 2 3 |
const settingsMap = new Map( Object.entries(settings) ) as MapFromObject<Settings, keyof Settings>; |
Now, every time we use the get method, we get an exact type of property.
1 |
settingsMap.get('role').toLowerCase(); // 'admin' |
If you have some other solution to the above issue, feel free to share it
If you need, you can also provide types for the set function in a similar manner.
Mapped types
The mapped types allow us to create new types from existing ones. A common use case is to make all of the properties of an object read-only.
1 2 3 |
type Readonly<ObjectType> = { readonly [KeyType in keyof ObjectType]: ObjectType[KeyType]; } |
The above is such a common use-case that we now have a Readonly type built-in and ready to use.
An example of its usage is the Object.freeze function. Let’s look into how TypeScript handles it:
1 2 3 4 5 6 7 8 |
interface ObjectConstructor { /** * Prevents the modification of existing property attributes and values, * and prevents the addition of new properties. * @param o Object on which to lock the attributes. */ freeze<T>(o: T): Readonly<T>; } |
As we can see, the Object.freeze function returns the object that is mapped using the Readonly modifier.
TypeScript developers identified more useful modifiers that might come in handy, such as Pick.
1 2 3 4 5 6 |
/** * From T, pick a set of properties whose keys are in the union K */ type Pick<T, K extends keyof T> = { [P in K]: T[P]; }; |
Even though we have a set of modifiers for different occasions, we might need to write some custom ones. When doing so, conditional types might be of some use.
Conditional type selects one of two types based on a condition
1 2 3 |
type WithNumbersInsteadOfStrings<ObjectType> = { [PropertyType in keyof ObjectType]: ObjectType[PropertyType] extends number ? string : ObjectType[PropertyType]; }; |
1 2 3 4 5 6 |
const user: WithNumbersInsteadOfStrings<User> = { id: '15', name: 'John', email: 'john@smith.com', role: 'admin' }; |
Summary
In this article, we’ve expanded more on the subject of generics in TypeScript. We’ve investigated the indexed types and mapped types. When doing so, we’ve also learned the keyof keyword, the union types. We’ve also stumbled upon string literal types and conditional types. Learning all of the above will definitely expand our TypeScript knowledge!
Awesome!