Editor’s note: This post was updated by Yan Sun on 24 April 2024 to update code blocks, include best practices for working with TypeScript generics, and troubleshoot common issues like Type is not generic
and Cannot Find Name 'T'
.
Generics are a powerful tool that can assist us in creating reusable functions. In TypeScript, variables and other data structures can be declared to be of a specific type, like an object, Boolean, or string type. With the help of generics, we can handle multiple types of these variables passed to a function.
In this article, we’ll learn how to achieve type safety via generics without sacrificing performance or efficiency. Generics allow us to define a type parameter between angle brackets, like <T>
. They also allow us to write generic classes, methods, and functions.
We’ll take a deep dive into the use of generics in TypeScript, demonstrating how to use them in functions, classes, and interfaces. We’ll look at passing default generic values, multiple values, and conditional values to generics. Lastly, we’ll discuss how to add constraints to generics.
Generics in TypeScript are a method for creating reusable components or functions that can handle multiple types. They allow us to build data structures without setting a specific time for them to execute at compile time.
In TypeScript, generics serve the same purpose of writing reusable, type-safe code where the type of variable is known at compile time. This means we can dynamically define the type of parameter or function that will be declared beforehand. This comes in handy when we need to use certain logic inside our application; with these reusable pieces of logic, we can create functions that take in and give out their own types.
We can use generics to implement checks at compile time, eliminate type castings, and implement additional generic functions across our application. Without generics, our application code would compile at some point, but we may not get the expected results, which can potentially push bugs into production.
By using generics, we can parameterize types. This powerful feature can help us create reusable, generalized, and type-safe classes, interfaces, and functions.
Let’s consider the following example. Below is a simple function that will add new properties to an array of objects. We have defined an interface for our object, which will take in an id
and a pet
:
interface MyObject { id: number; pet: string; } const myArray: MyObject[] = [ { id: 1, pet: "dog" }, { id: 2, pet: "cat" }, ]; const newPropertyKey = "checkup"; const newPropertyValue: string = '2023-12-03'; const newPropertyAddition = myArray.map((obj) => ({ ...obj, [newPropertyKey]: newPropertyValue, })); console.log(newPropertyAddition);
As you can see, we have already introduced a few constraints inside of our code. Let’s say we have a property that accepts a string and we want to add a new property that accepts a number. Instead of building another function, it would be great if we could reuse the original function.
This is the sweet spot where TypeScript generics kick in!
Let’s look at the problem before we write our solution using generics. If we pass the above function an array of strings, it would throw us this error:
'Type ‘number’ is not assignable to type of ‘string’
We can fix this by adding any
to our type declaration:
interface MyObject { id: number; pet: string; } const myArray: MyObject[] = [ { id: 1, pet: "dog" }, { id: 2, pet: "cat" }, ]; const newPropertyKey = "checkup"; const newPropertyValue: any = 20231203; const newPropertyAddition = myArray.map((obj) => ({ ...obj, [newPropertyKey]: newPropertyValue, })); console.log(newPropertyAddition);
However, if we do not define specific data types, using TypeScript isn’t necessary. Let’s refactor this piece of function to use generics:
type MyArray<T> = Array<T>; type AddNewProperty<T> = { [K in keyof T]: T[K]; } & { newProperty: string }; // generic 'newProperty' set to the desired with a name and type interface MyObject { id: number; pet: string; } const myArray: MyArray<MyObject> = [ { id: 1, pet: "dog" }, { id: 2, pet: "cat" }, ]; const newPropertyAddition: MyArray<AddNewProperty<MyObject>> = myArray.map((obj) => ({ ...obj, newProperty: "New value", })); console.log(newPropertyAddition);
Here, we denoted a type named <T>
, which will make it act more generic. It will hold the type of data that is received by the function itself. This means the type of the function is now parameterized in terms of a type parameter <T>
.
We first define a generic type representing an array of objects and create another type, AddNewProperty
, which adds a new property to each object in the array.
To improve clarity, we could create a function that takes in a generic as an argument and returns a generic itself. This is what generics are all about — creating one function that can be reused in multiple places:
function genericsPassed<T>(arg: T): [T] { console.log(typeof(arg)) return [arg] } /// type of argument passed through generics genericsPassed(3) //Passed a number genericsPassed(new Date()) //Passed a date object genericsPassed(new RegExp("/([A-Z])\w+/g")) //Passed a regex
Let’s take a look at an example of using generics in classes. Consider the following:
class MyObject<T> { id: number; pet: string; checkup: T; constructor(id: number, pet: string, additionalProperty: T) { this.id = id; this.pet = pet; this.checkup = additionalProperty; } } const myArray: MyObject<string>[] = [ new MyObject(1, "cat", "false"), new MyObject(2, "dog", "true"), ]; const newPropertyAddition: MyObject<number | boolean>[] = myArray.map((obj) => { return new MyObject(obj.id, obj.pet, obj.id % 2 === 0); }); console.log(newPropertyAddition);
Here, we created a simple class named MyObject
, which contains a variable that is an array of id
, pet
, and checkup
. We also define a generic class, MyObject<T>
that represents an object with properties id
, pet
, and an additional property, additionalProperty
, of type T
. The constructor accepts values for these properties.
Generics are not specifically tied to functions and classes. We can also use generics in TypeScript inside an interface. Generic interfaces use type parameters as placeholders to represent unknown data types. When we use the generic interface, we fill these placeholders with concrete types, customizing the structure for our needs.
Let’s take a look at an example of how can we use it in action:
const currentlyLoggedIn = (obj: object): object => { let isOnline = true; return {...obj, online: isOnline}; } const user = currentlyLoggedIn({name: 'Ben', email: '[email protected]'}); const currentStatus = user.online
With the above lines written, we get an error with a squiggly line telling us that we cannot access the property of isOnline
from the user:
Property 'isOnline' does not exist on type 'object'.
This is primarily because the function currentlyLoggedIn
does not know the type of object it is receiving through the object type we added to the parameter. We can get around this by using a generic:
const currentlyLoggedIn = <T extends object>(obj: T) => { let isOnline = true; return {...obj, online: isOnline}; } const user = currentlyLoggedIn({name: 'Benny barks', email: '[email protected]'}); user.online = false;
The shape of the object we are currently dealing with in our function can be defined in the interface below. In this example, <T>
is the type parameter. We can replace it with any valid TypeScript type when using the interface:
interface User<T> { name: string; email: string; online: boolean; skills: T; } const newUser: User<string[]> = { name: "Benny barks", email: "[email protected]", online: false, skills: ["chewing", "barking"], }; const brandNewUser: User<number[]> = { name: "Benny barks", email: "[email protected]", online: false, skills: [2456234, 243534], };
Below is a real-world example of how we can use a generic interface. We created an ILogger
interface. The interface defines a log
method that takes a message and data of any type (T
):
interface ILogger<T> { log(message: string, data: T); }
The ILogger
interface can be used with any data type, making our code more adaptable to different scenarios. It also ensures that the data being logged is of the correct type.
Firstly, let’s create a ConsoleLogger
class that implements the ILogger
interface:
class ConsoleLogger implements ILogger<any> { log(message: string, data: any) { console.log(`${message}:`, data); } } const user = { name: "John Lee", age: 22 }; const consoleLogger = new ConsoleLogger(); consoleLogger.log("New user added", user);
We can use ConsoleLogger
to print messages and any kind of data to the console.
Next, we can create a FileLogger
that implements the ILogger
interface to log messages to a file:
class FileLogger implements ILogger<string> { private filename: string; constructor(filename: string) { this.filename = filename; } log(message: string, data: string): void { console.log(`Writing to file: ${this.filename}`); // ... write logEntry to file ... } } const fileLogger = new FileLogger("userlog.txt"); fileLogger.log("User information", JSON.stringify(user));
Using the generic interface ILogger
, we can implement a concert logger class to handle the logging of any data type, making our code more flexible.
We can also pass in a default generic type to our generic. This is useful in cases where we don’t want to pass in the data type we are dealing with in our function by force. In the example below, we are setting it to a number
type by default:
function removeRandomArrayItem<T = number>(arr: Array<T>): Array<T> { const randomIndex = Math.floor(Math.random() * arr.length); return arr.splice(randomIndex, 1); } console.log(removeRandomArrayItem([45345, 3453, 356753, 3562345, 3567235]));
This snippet reflects how we used the default generic type on our removeRandomArray
function. With this, we can pass a default generic type of number
.
If we want our reusable blocks of functions to take in multiple generics, we can execute the following:
function removeRandomAndMultiply<T = number, Y = number>(arr: Array<T>, multiply: Y): [T[], Y] { const randomIndex = Math.floor(Math.random() * arr.length); const multipliedVal = arr.splice(randomIndex, 1); return [multipliedVal, multiply]; } console.log(removeRandomAndMultiply([45345, 3453, 356753, 3562345, 3567235], 608));
Here, we modified our previous function to introduce another generic parameter. We denoted it with the letter Y
, which is set to a default type of number
because it will multiply the random number we picked from the given array.
Because we’re multiplying numbers, we are definitely dealing with a number
type, so we can pass the default generic number
type.
Sometimes, we may want to pass a definitive number of values that pass a required condition. We can achieve this by defining a class with a conditional generic type parameter like the following:
class MyNewClass<T extends { id: number }> { petOwner: T[]; constructor(pets: T[]) { this.petOwner = pets; } processPets<X>(callback: (pet: T) => X): X[] { return this.petOwner.map(callback); } } interface MyObject { id: number; pet: string; } const myArray: MyObject[] = [ { id: 1, pet: "Dog" }, { id: 2, pet: "Cat" }, ]; const myClass = new MyNewClass(myArray); const whichPet = myClass.processPets((item) => { // Add conditional logic based on item properties if (item.pet === 'Dog') { return "You have a dog as a pet!"; } else { return "You have a cat as a pet!"; } }); console.log(whichPet);
In the above code, we’ve defined a class, MyNewClass<T extends { id: number }>
, that will take in a generic type of parameter <T>
, which will extend an object with an id
property with a number type. The class has an empty array property, petOwner
, of type T
to hold the items.
The processPets
method of MyNewClass
accepts a callback that will go through each item and check the defined condition. The returned value of whichPet
will be an array of values based on the conditions provided in the callback function. We can add conditions and define logic to fine-tune our requirements according to needs and specificity.
Generics allow us to work with any data types passed as arguments. We can, however, add constraints to the generic to limit it to a specific type.
A type parameter can be declared as limited by another type parameter. This will help us add constraints upon the object, ensuring we don’t obtain a property that may not exist:
function getObjProperty<Type, Key extends keyof Type>(obj: Type, key: Key) { return obj[key]; } let x = { name: "Benny barks", address: "New York", phone: 7245624534534, admin: false }; getObjProperty(x, "name"); getObjProperty(x, "admin"); getObjProperty(x, "loggedIn"); //property doesn't exist
In the above example, we created a constraint to the second parameter the function receives. We can invoke this function with the respective arguments, and everything will work unless we pass a property name that does not exist in the object type with the value of x
. This is how we can constrict object definition properties using generics.
Generics allow us to define functions and data structures with various data types and maintain type safety at the same time.
When the type isn’t known until runtime, we can define functions with generic types; these generic types will be replaced with specific types at runtime. Passing generic type parameters can help us process arrays with various data types, deserialize JSON data, or process dynamic HTTP response data.
Let’s say we are building a web app that interacts with an API. We need to create an API client that can handle different API responses with various data structures. We can define an API service as follows:
interface ApiResponse<T> { data: T; } class ApiService { private readonly baseUrl: string; constructor(baseUrl: string) { this.baseUrl = baseUrl; } public async get<T>(url: string): Promise<ApiResponse<T>> { const response = await fetch(`${this.baseUrl}${url}`); const data = await response.json() as T; return { data }; } }
Here, we define an interface ApiResponse<T>
that represents a generic API response structure. It includes a data
property of type T
and can be extended with other properties (e.g., status, error messages). Then, we create an ApiService
class that includes a generic function that takes a URL path and returns a Promise of ApiResponse<T>
. The function fetches data from the provided URL, parses, and asserts the JSON response (data as T
).
With the generic type, the ApiService
class is reusable for different API endpoints by changing the type parameter T
in the get
function. As shown in the following example, we can use the same apiClient
to call the following two endpoints to get a client and products:
interface Client { } interface Product { } async function getClient(id: number): Promise<Client> { const response = await apiClient.get<Client>(`/clients/${id}`); return response.data; } async function getProducts(): Promise<Product[]> { const response = await apiClient.get<Product[]>("/products"); return response.data; }
TypeScript generics are powerful tools, but using them in large codebases requires understanding best practices.
When working with generics, use descriptive names for clarity. Prioritize clear and descriptive type parameters when defining generic interfaces or functions. Use names that accurately reflect the expected data types.
For example, we define a doubleValue<T>
below. This generic function expresses the expected type and intention of the function, making the code more readable and maintainable:
function doubleValue<T extends number>(value: T): T { return value * 2; }
Additionally, apply constraints when necessary. Use type constraints (extends
keyword) to restrict the types that can be used with generics, ensuring that only compatible types are accepted.
In the example below, a generic interface is defined and applied as a parameter constraint, so the findById
function will accept only objects implementing the specific interface:
interface Identifiable<T> { id: T; } function findById<T, U extends Identifiable<T>>(collection: U[], id: T) { return collection.find(item => item.id === id); }
It is also important to leverage utility types. TypeScript provides utility types such as Partial<T>
, Readonly<T>
, and Pick<T, K>
to facilitate common data manipulations. These can enhance our code and its readability:
// Partial<T> creates a type with optional properties type UserPartial = Partial<User>; const userData: UserPartial = { name: "Alice" }; // Only give a subset of properties
When working with TypeScript generics, we often encounter issues like “type is not generic"
Troubleshooting these issues requires a systematic approach and an understanding of how generics work in TypeScript. Below is a list of common issues and strategies for troubleshooting them.
“Type is not generic"
/“Generic type requires type argument"
This error often arises when you use a generic type without providing the necessary type arguments or use a non-generic type with type parameters. The solution is to specify the type of elements the array should hold. For example, in the code snippet below, the fix is to add a type argument as const foo: Array<number> = [1, 2, 3];
:
interface User { id: number } // Attempt to use User as a generic parameter const user : User<number> = {}; //Type is not generice const foo: Array = [1, 2, 3]; //Generic type 'Array<T>' requires 1 type argument(s).
"Cannot Find Name 'T'"
This error typically occurs when using a type parameter (T
) that is not declared or not in scope. To fix this, declare the type parameter properly or check for typos in its usage:
// Attempting to use T as a generic type parameter without declaration function getValue(value: T): T { // Cannot find name 'T'. return value; } // Fixing the error by declaring T as a generic type parameter function getValue<T>(value: T): T { return value; }
In this article, we explored the significant advantages of using generics to create reusable components in TypeScript. We demonstrated how to implement generics to create functions, classes, and interfaces. By implementing generics, you can enhance your code by reducing the risk of runtime errors and improving code maintainability.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowHandle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
Design React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.
One Reply to "Using TypeScript generics to create reusable components"
“and that it will take a parameter of item, which is a type of array consisting of numbers. Finally, this function returns a value, which is also an array of numbers.
As you can see, we have already introduced a few constraints inside of our code. Let’s say want to loop through an array of numbers instead ”
array of numbers instead of an array of numbers?