Advanced typing in TypeScript with generics
Generics in TypeScript are the key component that allow you to define and use types more flexibly. There are three main generic function types: conditional types, mapped types, and template literal types.
In this article, you will learn about each of these three generic types: what they are and how to use them. I'll illustrate them with examples, so you’ll have a chance to better understand them. Let's dig in!
The conditional types functionality allows you to define types that depend on a condition. The keyword extends is used to define conditional types. The rest of the expression looks like this in the conditional operator after the ? character, the value for truth, and after the sign :, the value for untruth. Below is a simple example, in which we check whether the type passed is a number.
In this case, if the specified generic type T extends the number type, true is returned. Otherwise, it will be returned as false.
Like conditional operators, conditional types can be nested, that is, created as types that depend on other conditional types.
The NestedExample type depends on two interfaces — Dog and Animal. In the first condition, we check whether the Dog interface extends to the Animal. This is true, so we move on to the next condition, where we check if RegExp extends to Animal. This time the condition is false, so the value that NestedExample will assume is a boolean.
Types in TypeScript are static, which means that each value is known at the time of writing the code. However, in the case of conditional types, it may be difficult to determine the type based on the condition. The keyword infers, which refers to the type inference mechanism, comes in handy. This mechanism allows you to automatically infer types based on the values that functions or variables return. In practise, this means that it is not always necessary to explicitly declare the types in the code. If the value assigned to a variable has a specific type, TypeScript will be able to infer it and automatically assign it. Thanks to infer, we can use this mechanism when creating conditional types.
In this example, in ReturnType, we use infer to write the type that the generic type T returns in R and check whether it is a function. If the condition is true, we will get the type that provides the passed function (Example1 - void); otherwise, we will get any (Example2).
With mapped types, you can dynamically create new types based on existing ones. In TypeScript, we have several built-in mapped types, e.g., Partial, Pick, or Capitalise. Of course, you can also create your own.
The mapping mechanism consists of iterating on the properties of a given type and modifying them according to a specific rule. The operator keyof can be used for iteration after properties. It returns a set of keys of a given type.
Additionally, keywords in and as can be used to create mapped types. Thanks to them, we can modify individuals and their properties.
In the above example, we have defined the Messenger interface, which has two methods: sendText and sendFile. On its basis, AsyncMessenger was created, which uses the Async type to transform the obtained generic T type into a type containing the same keys (their names are hidden under the Property), but the allowed value will be the function that returns Promise<void>. Therefore, AsyncMessenger has the same property names as the Messenger interface; however, instead of functions that return void, they will return Promise<void>.
However, if we want to change the name of the keys, for example, to start with a capital letter, we can do it with the as mentioned earlier.
We are redefining the Messenger interface, the AsyncMessengertype, which is built on its basis, and the Async type. The difference is that instead of unchanged key names, as in the previous example, we will get keys starting with a capital letter — SendText and SendFile. As before, under Property will be the names of the keys, but this time they were packed in Capitalize. It is a built-in type in TypeScript, adopting a string and changing its first letter to a capital. Since the keys in the objects do not necessarily have to be a string, we need to make sure the compiler knows that Property is a string (& string).
Template Literal types
The basic element of the template literal type syntax is the backtick (`), which is a character of the inverted apostrophe surrounding the string of characters. For example, a declaration of type for a constant storing a URL might look like this:
In this case, the Url type defines a string starting with the HTTPS protocol and containing two strings separated by a dot. Hence, the variable url1 is valid, and at url2 TypeScript indicates an error.
Template Literal types can also be easily used for key names in the interface.
In this case, thanks to the use of template literal types, the Person type can contain any number of phone fields ending in a number.
You can read more about Template Literal types in this article.
Let's put these generics together!
All the previously discussed issues can be easily combined when creating types. In the last example, we will define the RequiredMessengerProperties type, which contains only the required fields of the Messenger interface. Then, based on it, we will create a FeatureFlags type, defining the available feature flags.
The Messenger interface defines the structure of the object containing the sendText, sendFile, and optionally checkStatus methods. These methods do not return any values (void).
RequiredFields defines a new type that contains only those properties of the generic type T that are marked as required (Required is a built-in TypeScript type that defines a field as mandatory). This is made possible by using the keyof operator, which returns a type consisting of key names. The as operator allows you to filter the properties of an object that meet certain conditions. In this case, we use never to remove fields that are not mandatory. RequiredMessengerProperties defines a new type that contains only the required properties from the Messenger interface, i.e., sendText and sendFile.
In FeatureOptions, based on the T generic type property names, new keys starting with is are generated, then the first letter is capitalised, and finally, the Enabled is glued. Each of the keys will be able to receive a boolean as a value. By using the RequiredMessengerProperties type in FeatureFlags, property names will be generated based on the required Messenger interface properties — isSendTextEnabled and isSendFileEnabled.
Conditional types, Mapped types, and Template Literal types are really powerful tools provided by TypeScript. Each of these topics enables advanced work with types, which allows us to be more flexible and reduces code repeatability.