TypeScript Generics
Generics don't exist in JavaScript but are one of the essential concepts in TypeScript. They offer the best of both worlds: flexibility and type safety.
Last updated: April 19, 2023Let's continue our TypeScript series. Today, we will look at a concept that's pretty confusing for me - generics. If you're coming from other languages like C# or Java, you're probably familiar with them. But there is a high chance you're like me - focused mainly on JavaScript (and maybe you're confused too). There is no such concept in the vanilla language and it may cause problems. But it's an essential concept of TypeScript. So, I'll explain generics (to you and myself) like we're eight years old.
What is a Generic?
Usually, when we work with types, we define them like this.
Defining Types in TypeScriptTS
1let person: string2person = 'Sam'34let surname = 'Fisher'
You define a variable, initialize it (or not), define a type, or let TypeScript infers it. And it's done. The type won't change. That's why we use TypeScript, right? Yes, but sometimes we want to be more flexible. However, we want to avoid returning to the dynamic types of JavaScript. To achieve balance and type-safety, here comes - generics. A generic is like a variable but for types. A variable can receive different values - a generic can accept different types. A generic is a type connected with some other one, and it's flexible regarding which that other type is. What do I mean by that?
Built-in Generics
If you look closely, some basic types like Array or Promise are generics in TypeScript. In vanilla JavaScript, arrays can store multiple different types. We want to be more precise using TS. For example, defining an array of strings can give TS more information and give us better auto-completion. We can write it two ways. We can use the standard syntax…
Basic Types and GenericsTS
1let hobbies: string[] // The standard syntax.2let hobbies: Array<string> // The other syntax using generics.3hobbies = ['sneaking']45// TypeScript infers it's a string and provides auto-completion.6hobbies[0].split('')
…or we can use generics. It's an array of strings. It's a type connected to another one. A promise is also a generic type. It works with other types because it eventually returns other ones.
Generic Function
An alternative syntax for basic types is ok, but now let's write some custom generics. Let's see these variables for types in action.
Generic FunctionTS
1function merge<T, U>(a: T, b: U) {2 return {3 ...a,4 ...b5 }6}78merge<string, string>('Sam', 'Fisher')9merge<string[], string[]>(['Sam'], ['Fisher'])10merge({ name: 'Sam' }, { hobbies: ['sneaking'] })
To define generics, we need additional pair of (pointing) brackets. The convention is to define generics using single letters starting from the "T" letter. The "a" argument is of type "T," and the "b" is of type "U." These types are not static. They are dynamic - set when we call the function. TypeScript can still infer the return value and provide auto-completion. Generics allow you to work with your data in TS optimal way. Look at the above snippet - we can pass objects, arrays, or strings. Generic functions allow you to fill concrete types for different function calls. "So, I can pass other types like numbers too?" Yes, and that is problematic because we use the spread operator.
Generic Constraints
Generic constraints come to the rescue. Sometimes we want flexibility, but at the same time, we want some validation. You can set certain limitations for the type generic is based on. You can use the extends keyword to define generic constraints.
Generic ConstraintsTS
1function merge<T extends object, U extends object>(a: T, b: U) {2 return {3 ...a,4 ...b5 }6}78// TypeScript error:9// Argument of type 'string' is not assignable to parameter of type 'object'.10merge('Sam', 'Fisher')
Now our function only accepts objects, and it avoids runtime errors. But let's go further with our example. Let's say that later in the code, we want to be sure that there is a specific property. We can define interfaces and tell our parameters to extend them.
Generic InterfacesTS
1interface BasicInfo {2 name: string3}45interface AdditionalInfo {6 hobbies: string[]7}89function merge<T extends BasicInfo, U extends AdditionalInfo>(a: T, b: U) {10 return {11 ...a,12 ...b13 }14}1516const person = merge({ name: 'Sam' }, { hobbies: ['sneaking'] })17person.name // You can be sure there is the name property.
Let's imagine another scenario - we want to access an object property by passing a string. In such a scenario, we can use the keyof keyword. The first parameter in the below example should be an object. The second parameter should be any key in that object. The keyof operator takes an object type and produces a string or numeric literal union of its keys.
The keyof keywordTS
1function extract<T extends object, U extends keyof T>(obj: T, key: U) {2 return obj[key]3}45extract({ name: 'Sam' }, 'name')6// TypeScript error:7// Argument of type '"hobbies"' is not assignable to parameter of type '"name"'.8extract({ name: 'Sam' }, 'hobbies')
To read more about literal or union types, check my post about basic TypeScript types.
Generic Class
Similarly to functions, you can write generic classes. The syntax is the same - using angled brackets. Such a class can be both flexible and strongly typed.
Generic ClassTS
1class Data<T extends string | number | boolean> {2 private data: T[] = []34 addItem(item: T) {5 this.data.push(item)6 }78 removeItem(item: T) {9 this.data.splice(this.data.indexOf(item), 1)10 }11}1213const text = new Data<string>()14text.addItem('Sam')15text.addItem(50) // TypeScript error1617const number = new Data<number>()18number.addItem(50)
We didn't lock the above class to one type. At first, TypeScript doesn't know which type it is. But then TypeScript knows the concrete type you pass in - when you call the function or instantiate the class. You can even be more granular and introduce new generic types in class methods or properties. That’s a good use case when you don't need them in the entire class.
Generics inside classesTS
1class Data {2 constructor(private data: Array<string | number | boolean> = []) {}34 getItems() {5 return [...this.data]6 }7}89const string = new Data(['Sam', 'Fisher'])10const number = new Data([68])11const object = new Data([{ name: 'Sam' }]) // TypeScript error.
Generic vs. Union Types
Seeing the previous snippet, you may ask, "how that's different from the union type?" Yeah, they look similar, but they work differently. I think the best way to explain the difference will be to use examples. Let's go back to our class.
Generic vs. Union TypesTS
1class Data<T extends string | number | boolean> {2 private data: T[] = []34 addItem(item: T) {5 this.data.push(item)6 }78 removeItem(item: T) {9 this.data.splice(this.data.indexOf(item), 1)10 }11}
Writing our class like this using generics, we say: "You have to choose the type of data you want to store, and then you're only allowed to add that type of data." Use generics if you want flexibility but then want to stick to a specific type. It's a different story using union types.
Generic vs. Union TypesTS
1class Data {2 private data: (string | number | boolean)[] = []34 addItem(item: string | number | boolean) {5 this.data.push(item)6 }78 removeItem(item: string | number | boolean) {9 this.data.splice(this.data.indexOf(item), 1)10 }11}
Here we're not saying this is either an array of strings, or array of numbers, or an array of booleans. The array can store strings, numbers, and booleans mixed. It's the same for functions. With union types, we accept any of these types every time we call the method.
Generic Utility Types
There are also many examples of generic utility types. They are like a set of helper functions that assist you with common type transformations. These types are available globally and were added gradually to new TypeScript versions. I list a couple of them.
Partial
In vanilla JavaScript, you can create an object and then add properties. TypeScript doesn't like on-fly adding. But let's say you need such a functionality.
Partial Utility TypeTS
1interface Computer {2 name: string3 description: string4}56function updateComputer(computer: Computer, fieldsToUpdate: Partial<Computer>) {7 return {8 ...computer,9 ...fieldsToUpdate10 }11}1213const desktop: Computer = {14 name: 'MacBook Pro',15 description: 'It has a dedicated GPU.'16}1718const laptop = updateComputer(desktop, { description: 'It has a 16" display.' })
Partial wraps a type and changes its properties to optional. In our example, we must specify the name
and description
when creating an object. But, using partial, we don't need to pass both values when updating one of the objects. Fields are optional, and we can update them independently.
Required
It's the opposite of partial. It constructs a type consisting of all properties of the passed type set to required. With that knowledge, we can rewrite our previous snippet.
Required Utility TypeTS
1interface Computer {2 name?: string3 description?: string4}56function updateComputer(7 computer: Required<Computer>,8 fieldsToUpdate: Computer9) {10 return {11 ...computer,12 ...fieldsToUpdate13 }14}1516const desktop: Required<Computer> = {17 name: 'MacBook Pro',18 description: 'It has a dedicated GPU.'19}2021const laptop = updateComputer(desktop, { description: 'It has a 16" display.' })
Readonly
It works similarly to the other two. The difference is it changes all properties to read-only. You can't reassign them later.
Readonly Utility TypeTS
1interface Computer {2 id: string3}45const laptop: Readonly<Computer> = {6 id: 'ga6sd798'7}89// Cannot assign to 'id' because it is a read-only property.10laptop.id = 'ags98kd'
It wouldn't make much sense to list all utility types and copy the TypeScript documentation, so I'll end here. Grammarly (spell checking tool I use) says that "Your text is likely to be understood by a reader who has at least an 8th-grade education (age 13-14) and should be fairly easy for most adults to read." That's over eight years old, but I hope the post was clear anyway. Generic types are there to make your life easier and give you a combination of flexibility and type safety. I hope this post wasn't too generic, and now you can use generics to your advantage.