Overview
This chapter introduces generics and conditional types. This chapter first teaches you about what generics are, and some basic generics usage in different contexts – interfaces, classes, functions, and so on. Next, you'll learn about generic constraints, and how to make your code more type-safe while using generics, to avoid errors at runtime. Lastly, you'll learn about conditional types and how they make generics even more powerful by introducing type-level logic at compile time.
By the end of this chapter, you will be able to apply generics to real-world use cases.
In the previous chapter, we saw how we can use dependency injection in TypeScript. In this chapter, we'll cover two of the more advanced features that TypeScript's type system offers, useful mostly in advanced applications or when building libraries – generics and conditional types.
TypeScript includes a very strong type system that covers a lot of use cases and advanced types. In earlier chapters, we saw some of the more basic ways in which you can utilize the type system while building applications.
Generics are one of the building blocks of many languages, such as Java, C#, Rust, and of course TypeScript, and they aim to allow developers to write dynamic and reuseable generic pieces of code with types that are unknown when writing the code but will be specified later, when using these generic pieces of code. In other words, generics are a sort of "placeholder" when the concrete type isn't known at the time of creating an application.
For example, if you want to write a generic List data structure, the implementation is the same for whatever type of item it may store, but the actual type of item is unknown when writing the List class. We can then use generics as a sort of a "placeholder" type when writing it, and the user of the List class will specify it when they know the concrete type it'll use, thereby filling in this "placeholder."
Conditional types allow us to bring logic into TypeScript's type system, which will be checked at compile time. This means that our types can be safer, and we can make code stricter, and move some of our logic from runtime to compile time, which means that less code needs to run on the server or in the user's browser. Additionally, conditional types allow us to write more complex types, with more complex relations between them.
For example, if we want to remove some options from a string literal union, we can use the Extract type to only take some of them:
type Only FooAndBar = Extract<"foo" | "bar" | "baz", "foo" | "bar">; // "foo" | "bar"
While not restricted to usage with generic types, conditional types are usually used in these cases, since you want to write some logic on a type unknown and ahead of time, because otherwise, you could write it explicitly yourself.
In this chapter, we'll explore both generics and conditional types and see how they can make your code more robust, resilient to changes, and offer a better developer experience when used externally.
As mentioned, generics help us write code that has types that are unknown when writing it but will be known later on, when someone uses the code. They allow us to put "placeholders" where concrete types would've been used otherwise, and for these placeholders to be filled in later, by the user of our code. Generics allow us to write a code once, and use it for multiple types, without losing type-safety along the way, or even increasing the type-safety in comparison to what we can achieve without it.
Let's see how generics help us with typing things more correctly, starting with a very basic function—identity:
// identity.ts
function identity(x: number): number {
return x;
}
The identity function takes in a number, x, and just returns x. Now, let's say we want the same functionality for strings too:
// identityString.ts
function identityString(x: string) {
return x;
}
Since type information is just for compile time, the two functions are the exact same in the compiled JavaScript output:
// identity.js
function identity(x) {
return x;
}
// identityString.js
function identityString(x) {
return x;
}
Since the output JavaScript code is the same and given that TypeScript only adds types on top of existing JavaScript, there's a way to type this existing identity function such that it'll support both use cases. We can type identity in multiple ways – the most simple way is to type x as any. However, this means we lose type-safety inside the function, not to mention in the return type:
function identity(x: any): any {
return x;
}
const result = identity('foo');
result.toFixed();
This is probably not what we want. Since result is of type any, TypeScript cannot know that result.toFixed() in the preceding code will throw an error at runtime (since strings don't have a toFixed() method):
Figure 9.1: Running this code results in a TypeError at runtime
Instead, we can leverage generics – we'll type x as a generic type T, and return the same type from the function. Consider the following code:
function identity<T>(x: T): T {
return x;
}
In TypeScript, generics are written using angled brackets, and a placeholder type name between them. In the preceding code, T is generic and serves as a "placeholder." Now if we update the code with the following details, we will get a compile-time error as shown here (red underline):
Figure 9.2: Compile-time error due to generics being used
Note
The placeholder type name can be anything, and its name is only useful for the developer using the code – so try to give generic types useful names that have meaning in the context they're used in.
Note that we only have a single function (identity) implementation that can be used with both strings and numbers. TypeScript also knows the return type automatically and can provide useful errors at compile time. Moreover, we can pass any other type to the identity function, without the need to modify it at all.
Note
We didn't even have to tell TypeScript what the type of the generic is when calling identity(). TypeScript can usually infer the type of the generic(s) itself from the arguments.
Usually, having to manually specify the type of the generic when calling a function is a code smell (a sign that the underlying code might contain a bigger problem), when it can be inferred from the arguments (though there are exceptions to this).
Generics come in all sorts of forms—from functions like we just saw, to interfaces, types, and classes. They all behave the same, just in their own scope—so function generics are only applicable for that function, while class generics are for that class's instance, and can also be used inside its methods/properties. In the next sections, we'll explore each of these types of generics.
Generic interfaces are interfaces that have some additional type, not previously known to the author of the interface, "attached" to them. This additional type gives "context" to the interface and allows better type-safety when using it.
In fact, if you've used TypeScript in the past, you've probably already interacted with generics, maybe without even realizing it. They are at play everywhere—just take a look at this basic line of code:
const arr = [1, 2, 3];
If you hover over arr, you'll see it's of type number[]:
Figure 9.3: The type of arr is inferred to be number[]
number[] is just a shorter syntax for Array<number> —generics at play again.
In arrays, generics are used for the type of elements that the array holds. Without generics, Array would have to be typed with any all over the place or have a separate interface for every type possible (including non-built-in ones, so that's out of the question).
Let's take a look at the Array<T> interface definition:
Figure 9.4: Some of the Array<T> interface, where generics are heavily used
As you can see, the pop, push, and concat methods all use the T generic type to know what they return, or what they can accept as arguments. This is why the following code doesn't compile:
Figure 9.5: An error when trying to push an incompatible type to an array with a specific generic type
This is also how TypeScript can infer the type of the value in the callback for map, filter, and forEach:
Figure 9.6: Type inference when using the map method of Array
Generics can be used on plain types, for example, to create a Dictionary<V> type, and also to describe a map between strings of any values of type V, which is unknown ahead of time, and therefore generic:
type Dictionary<V> = Record<string, V>;
There are more use cases for generic types, but mostly you'll either be using them together with generic constraints (explained later in this chapter) or describing them with interfaces (though mostly anything that an interface can do, a type can as well).
Generics are also very useful for classes. As we've seen earlier in the chapter, the built-in Array class uses generics. These generics are specified at the class's definition and apply to that instance of the class. Properties and methods of the class can then utilize that generic type for their own definitions.
For example, let's create a simple Box<T> class that holds a value of any type T and allows retrieving it later:
class Box<T> {
private _value: T;
constructor(value: T) {
this._value = value
}
get value(): T {
return this.value;
}
}
The _value property, the constructor, and the value getter use the T generic type from the class's definition for their own types. This type could also be used for other methods in this class if there were any.
Additionally, methods of the class can add their own generics, which will only apply to that method's scope – for example, if we wanted to add a map method to the Box class, we could type it like so:
class Box<T> {
...
map<U>(mapper: (value: T) => U): U {
return mapper(this.value)
}
}
The U generic type can be used inside the map method declaration, as well as within its implementation, but it cannot be used in other class members (like the value getter from earlier), unlike T – which is scoped to the entire class.
In this exercise, we'll create a Set<T> class that implements that Set data structure – a data structure that can hold items, without a specific order, and without duplications, using generics.
Follow these steps to implement this exercise:
Note
The code file for this exercise can be found here: https://packt.link/R336a.
class Set<T> {
}
class Set<T> {
private items: T[];
constructor(initialItems: T[] = []) {
this.items = initialItems;
}
}
We use default parameters to initialize initialItems with an empty array if we haven't been supplied with one – this makes this parameter optional, while still making it convenient to work with inside our constructor implementation.
class Set<T> {
private items: T[];
//...
get size(): number {
return this.items.length;
}
}
class Set<T> {
private items: T[];
//...
has(item: T): boolean {
return this.items.includes(item);
}
}
Notice that we use the T type in the has definition – we can use it since it's in the scope of the class, where T was declared.
class Set<T> {
...
add(item: T): void {
if (!this.has(item)) {
this.items.push(item);
}
}
remove(item: T): void {
const itemIndex = this.items.indexOf(item);
if (itemIndex >= 0) {
this.items.splice(itemIndex, 1);
}
}
}
For the add method, we first check whether the given item already exists, and if not, add it.
For the remove method, we look for the index of the given item. If it exists, we remove it from the array.
const set = new Set <number>([1,2,3]);
set.add(1) // works – since 1 is a number
set.add('hello') //Error – since 'hello' is not a number
On your IDE, you will see the following:
Figure 9.7: Type-safety in the Set class because of generics
We can see how the Set class can be used, and how it keeps itself type-safe, not allowing items of multiple types to be mixed together in the same class, for instance, in the following step 7.
Figure 9.8: Type-safety in the Set class because of generics
This is expected, since T can be of any type, and not just a string – as we saw in the preceding example where we created a Set<number> – a set that can only hold numbers.
We've already briefly seen generic functions at the beginning of this chapter with the identity<T>() function. But let's look at a more real-world, more useful use case—say you want to write a wrapper around fetch() for fetching JSON data, such that users won't have to call .json() on the response. Consider the following code:
interface FetchResponse {
status: number;
headers: Headers;
data: any;
}
async function fetchJson(url: string): Promise<FetchResponse> {
const response = await fetch(url);
return {
headers: response.headers,
status: response.status,
data: await response.json(),
};
}
Here, we use the browser's fetch function to make a GET call to the given url and then return an object with the main parts of the response – the headers, the status code (status), and the body, after parsing it as JSON (data).
Note
fetch() is not part of ECMAScript and is therefore not part of the language. It's available natively in all modern browsers and can be used in Node.js via packages such as node-fetch, isomorphic-fetch, and others.
The json() method returns Promise<any>. This means that the following code may throw at runtime, if the returned object doesn't have a title property, or it isn't of type string:
const { data } = await fetchJson('https://jsonplaceholder.typicode.com/todos/1');
console.log(data.title.toUpperCase()); // does data have a title property? What type is it?..
It would be useful if a consumer calling the fetchJson function could know what the type of data is. For that, we could add a generic type to the fetchJson function, which we'd also need to indicate in the return type somehow – that's where interface and type generics come in again. Consider the following code of fetchJson.ts:
// fetchJson.ts
interface FetchResponse<T> {
status: number;
headers: Headers;
data: T;
}
async function fetchJson<T>(url: string): Promise<FetchResponse<T>> {
const response = await fetch(url);
return {
headers: response.headers,
status: response.status,
data: await response.json(),
};
}
This is very similar to the first declaration of fetchJson seen previously. Actually, the resulting JavaScript is exactly the same. However, this declaration now uses generics to allow the users of the function to specify the return type expected from making the GET call.
Now consider the code of usage.ts:
// usage.ts
(async () => {
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
const { data } = await fetchJson<Todo>('https://jsonplaceholder.typicode.com/todos/1');
console.log(data.title); // ✅ title is of type 'string'
console.log(data.doesntExist); // ❌ 'doesntExist' doesn't compile
})();
Here, we allow the user to pass in a T generic type to fetchJson<T>(), which the function declaration later passes to the FetchResponse<T> interface, tying things together.
Note
Just like interfaces, generics only exist at compile time. So, anything you write there is as safe as you make the compiler understand it to be. For example, if you were to type Todo differently, or pass a different type, then the actual result – there is no guard built into TypeScript to verify it at runtime (without user/library code – see user type guard in Chapter 5, Inheritance and Interfaces).
Note that in the preceding example, the T generic is a convenience generic—it's only there for the user's convenience—it's only used once, and doesn't offer any more type-safety than a simple type assertion would:
const response = await fetchJson('https://jsonplaceholder.typicode.com/todos/1');
const todo = response.data as Todo;
Note that generics, just like variables, have scopes, and you can define generics at multiple levels, letting the user provide them as needed. For example, notice how we use the T generic type that's declared in the map function, in our inner function (in line 2 in the following snippet):
function map<T, U>(fn: (item: T) => U) {
return (items: T[]) => {
return items.map(fn);
};
}
const multiplier = map((x: number) => x * 2);
const multiplied = multiplier([1, 2, 3]); // returns: [2, 4, 6]
This applies to things such as interfaces and classes too. In the Array<T> interface, the map function takes an additional generic to be used as the output type, as can be seen in the Array<T> interface declaration in TypeScript:
interface Array<T> {
// ...
map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
// ...
}
Consider the following screenshot:
Figure 9.9: The map method of Array<T> has a return type inferred based on the type returned from callbackfn
Once we add the code shown above, again, we don't need to explicitly tell TypeScript that U is string – it can infer it from the return type of the callback function (though we could explicitly pass it if we wanted to). The map method of Array<T> has a return type inferred based on the type returned from callbackfn. It's inferred to string[] in this case.
Sometimes you want to define a generic to be constrained to some subset of types. At the beginning of this chapter, we looked at the identity function – there it was easy and made sense to support any type. But what about typing a getLength function – which only makes sense for arrays and strings. It doesn't make sense to accept just any type – what would the output of getLength(true) be? In order to constrain the type of values our function can accept, we can use generic constraints. Consider the following code:
function getLength<T extends any[] | string>(x: T): number {
return x.length;
}
This definition constrains the given T type to be a subtype of either any[] (an array of anything – string[], number[], or any Foo[] would all be valid types) or a string. If we pass an invalid type, we get a compilation error as you can see here:
Figure 9.10: Compile-time errors are given for invalid types when passed to the getLength function
There are many use cases for generic constraints, and more often than not you'll want to set some of these in place when using generics, since when writing the code, you probably assume some underlying type for it. Additionally, putting generic constraints lets TypeScript narrow the possible type of the generic type, and gives you better suggestions and type-checking.
For example, in a more real-world scenario, we might have some functions that return us plain dates while others return an epoch. We want to always work with dates, so we can create a function, toDate, that accepts these types and normalizes a Date function from them:
function toDate<T extends Date | number>(value: T) {
if (value instanceof Date) {
return value;
}
return new Date(value);
}
Here, we first check if the given value is a date. If so, we can just return it. Otherwise, we create a new Date function with the value and return that.
Generic constraints are especially powerful for creating higher-order functions, where typing the incoming function can be very hard, and keeping type-safety is a big benefit for code maintainability. In the next exercise, we'll see more uses for generic constraints in a real-world application and cases where it brings better typing to our code.
Note
Higher-order functions are functions that either take in another function as an argument or return a function. We'll explore these more in Chapter 12, Guide to Promises in TypeScript.
In this exercise, we'll create a memoize function that, using generics, will be completely type-safe—it takes in a function and returns a function of the same type.
Note
Memoization is a way to optimize performance, by reducing the number of times something is done. A memorization function is a higher-order function that caches the results of the inner function passed to it.
Follow these steps to implement this exercise:
Note
The code files for this exercise can be found here: https://packt.link/zUx6H.
function memoize(fn: Function, keyGetter?: (args: any[]) => string) {
// TODO: we'll implement the function in the next steps
}
memoize takes in a function, fn, to memoize, as well as an optional keyGetter to serialize the arguments to a key, used for later lookups.
function memoize(fn: Function, keyGetter?: (args: any[]) => string) {
const cache: Record<string, any> = {};
return (...args: any[]) => {
const key = (keyGetter || JSON.stringify)(args);
if (!(key in cache)) {
cache[key] = fn(...args);
}
return cache[key];
};
}
In the memoize function, we create an empty cache dictionary – the keys are the serialized arguments, and the values are the results of running the fn function on those arguments.
We then return a function that, given some arguments, args will check to see if the results for running fn with them have already been cached. If they haven't, we run fn with these arguments and cache the result. Lastly, we return the value we have stored in the cache, which is either a past calculation or the one we just ran and cached.
function expensiveCalculation(a: number, b: number) {
const timeout = 10000;
const start = Date.now();
while (Date.now() <= start + timeout);
return a + b;
}
Note
Since memoization is meant to reduce the number of calls, it is usually effective in functions that take a long time to run – to illustrate this, we made expensiveCalculation, a function that takes a needlessly long time to run (10 seconds).
const memoizedExpensiveCalculation = memoize(expensiveCalculation);
Notice that the memoized version is not type-safe. It does verify that we give it a function, but the returned value is a very loosely typed function, which may fail at runtime or have unexpected behavior if not typed correctly – you can pass in any number of arguments to it, with any type, and it will compile fine, even though at runtime the function expects to only be called with two arguments, both of which should be of type number.
Here we are memoizing with the following:
expensiveCalculation("not-a-number", 1);
memoizedExpensiveCalculation("not-a-number", 1);
Figure 9.11: Message on the IDE
As can be seen in the preceding screenshot, the memoized version of expensiveCalculation is not type-safe – it allows passing in a string as the first parameter, when it should only accept a number.
type AnyFunction = (...args: any[]) => any;
type KeyGetter<Fn extends AnyFunction> = (...args: Parameters<Fn>) => string;
The first type, AnyFunction, describes a function that takes any number of arguments and returns anything. The second type, KeyGetter, describes a function that takes in the parameters of the generically constrained function Fn and returns a string. Notice that we constrain Fn to be of type AnyFunction. This ensures that we get a function, and allows us to use the built-in Parameters<T> type, which takes in a type of a function and returns the parameters it takes.
function memoize<Fn extends AnyFunction>(fn: Fn, keyGetter?: KeyGetter<Fn>) {
Again, we constrain Fn to be of type AnyFunction to ensure we get a function, as we did before, as well as to be able to use the specific function type later, for our return type.
Now we have a more type-safe function, since keyGetter is now type-safe but it still doesn't return a typed function back.
function memoize<Fn extends AnyFunction>(fn: Fn, keyGetter?: KeyGetter<Fn>) {
const cache: Record<string, ReturnType<Fn>> = {};
return (...args: Parameters<Fn>) => {
const key = (keyGetter || JSON.stringify)(args);
if (!(key in cache)) {
cache[key] = fn(...args);
}
return cache[key];
};
}
We use ReturnType<Fn> for the values of our cache instead of any. ReturnType<T> is another built-in type that types in a type of a function and returns the return type of that function. We also use the Parameters<T> type again here, to describe the function we're returning from memoize.
Figure 9.12: The type of memoizedExpensiveCalculation is the same as the original expensiveCalculation function
This exercise demonstrates how generics can be used in functions and types, and how they integrate with one another. Using generics here is what allows the memoize function to be completely type-safe, so there is less chance of our code hitting errors during runtime.
Sometimes, you want to allow for generics, but not require them – you want to give some sensible defaults, but allow overriding them as needed. For example, consider the following definition of an Identifiable interface:
interface Identifiable<Id extends string | number = number> {
id: Id;
}
This can be used by other interfaces like so:
interface Person extends Identifiable<number> {
name: string;
age: number;
}
interface Car extends Identifiable<string> {
make: string;
}
declare const p: Person; // typeof p.id === 'number'
declare const c: Car; // typeof c.id === 'string';
The current implementation requires every implementer of the Identifiable interface to specify the type of Id it has. But maybe we want to give some default, so you only have to specify it if you don't want that default type. Consider the following code:
interface Identifiable<Id extends string | number = number> {
id: Id;
}
Notice the bolded code change. We give the Id generic type a default type of number, which simplifies the code for the implementors of this interface:
interface Person extends Identifiable {
name: string;
age: number;
}
interface Car extends Identifiable<string> {
make: string;
}
Note that now Person doesn't have to specify the type of Id, and the code is equivalent to before.
Another, more real-world, scenario is with React components—each React component may have props and may have state, both of which you can specify when declaring a component (by extending React's Component type), but it doesn't have to have either, so there's a default {} given to the generic type of both:
Figure 9.13: Partial snippet from the @types/react package
This makes React components have no props and no state by default, but these can be specified if they need either of them.
Conditional types were introduced in TypeScript 2.8 and allow complex type expressions, some of which drive some of the built-in types we saw earlier. These are really powerful, since they allow us to write logic inside our types. The syntax for this is T extends U ? X : Y. This is very similar to the regular JavaScript ternary operator, which allows for inline conditions, the only difference in the syntax is that you have to use the extends keyword and that this check is done at compile time and not runtime.
This allows us to write a NonNullable<T> type:
type NonNullable<T> = T extends null | undefined ? never : T;
This is already built into the language, but it's driven by the same code you could write in your app.
This means that you can check whether a type is nullable at compile time and change the type signature or inference based on that. An example use case for this would be an isNonNullable function. Consider the following code:
function isNonNullable<T>(x: T): x is NonNullable<T> {
return x !== null && x !== undefined;
}
The preceding code together with the filter method of Array can allow you to filter for relevant items. For example, consider the following definition of an array with items of mixed types:
Figure 9.14: The type of arr is an array, where each element is either number, null, or undefined
When we call arr.filter(isNonNullable), we can get a properly typed array:
Figure 9.15: The type of nonNullalbeArr is inferred to be number[]
Lastly, another addition to TypeScript in 2.8 was the infer keyword, which allows you to get help from the compiler in inferring the type of something, from another type.
Here's a simple example:
type ArrayItem<T extends any[]> = T extends Array<infer U> ? U : never;
Here, we want to get the inner type of an array (for example, for an array of type Person[], you want to get Person). So we check if the passed generic type T extends Array<infer U> the infer keyword suggests to the compiler that the compiler should try to understand what the type is, and assign that to U, which we then use as the return value from this conditional type.
Note
This specific example type was also possible in previous versions via type ArrayItem<T extends any[]> = T[number].
Another very useful example that was not previously possible outside of arrays was to "unbox" a type. For example, given the Promise<Foo> type, we want to get the Foo type back. This is now possible with the infer keyword.
Similarly to the last example, where we extracted the array inner type, we can use the same technique for any other generic type that "boxes" another type:
type PromiseValueType<T> = T extends Promise<any> ? T : never;
This will yield the following type information on the IDE:
Figure 9.16: The type of UnpromisedPerson is Person
In the next activity, we'll take a look at a more real-world use case for conditional types, as well as usage of the infer keyword.
In this activity, we'll be using concepts learned in this chapter—generics, conditional types, and the infer keyword—to create a DeepPartial<T> type. This type is like the built-in Partial<T> type. But we will work recursively and make every property in the object optional, recursively.
This will allow you to correctly type variables and so on so that all of their properties, at any level, can be optional. For example, a REST server will serve resources, and allow modifying them using a PATCH request, which should get a partial structure of the original resource, to modify.
Note
The code file for this activity can be found here: https://packt.link/YQUex.
To create this type, we'll need to deal with a few cases:
Perform the following steps to implement this activity:
Note
The solution to this activity can be found via this link.
This chapter got you started with the basics of generics and conditional types. We learned about generics in a lot of different use cases, why they are useful, as well as some extensions to their basic usage – generic defaults and conditional types. We performed a couple of exercises to show how you can include generics in your code to make it type-safe and avoid errors at runtime.
Generics are useful in all kinds of applications, both frontend and backend, and are used everywhere, but especially so in libraries, where a lot of the time, you want to expose an API that leverages the applications' types, which you might not know ahead of time.
In the next chapter, you'll learn about asynchronous development, some of which you encountered briefly in this chapter when typing external APIs.