Generics are one of the most powerful features in TypeScript, allowing you to create reusable components that work with a variety of types while maintaining full type safety. They provide a way to create flexible, type-safe code without sacrificing compilation checks.
Before diving into generics, let's understand why they're necessary. Consider this function that returns the input it receives:
Bad any
functionidentity(arg:any):any {return arg;}
This works but has a significant problem: we lose type information. When we pass in a number, we only know that any type is returned, not specifically a number.
With generics, we can preserve this type information:
Good generics
functionidentity<T>(arg:T):T {return arg;}
Now when we use this function, TypeScript knows that the return value will be of the same type as the input.
functionidentity<T>(arg:T):T {return arg;}// Explicit type parameterlet output1 =identity<string>("myString");// Type inference - TypeScript infers the type automaticallylet output2 =identity("myString");
In this example:
<T> declares a type parameter
arg: T means the argument is of type T
: T specifies that the function returns a value of type T
When calling the function, you can either explicitly specify the type or let TypeScript infer it
Multiple Type Parameters
You can use multiple type parameters:
Multiple types
functionpair<T,U>(first:T, second:U): [T,U] {return [first, second];}constpairResult=pair<string,number>("hello",42);// pairResult is of type [string, number]
Generic Constraints
Sometimes you need to restrict the types that a generic can use. This is done with the extends keyword:
Generic constraints
interfaceLengthwise { length:number;}functionloggingIdentity<TextendsLengthwise>(arg:T):T {// Now we know arg has a .length propertyconsole.log(arg.length);return arg;}loggingIdentity("hello"); // Works, string has a length propertyloggingIdentity([1,2,3]); // Works, arrays have a length property// loggingIdentity(3); // Error: Number doesn't have a length property
Here, T extends Lengthwise means the type parameter must have all the properties of the Lengthwise interface.
Using Type Parameters in Generic Constraints
You can also use a type parameter to constrain another type parameter:
Type parameters
functiongetProperty<T,KextendskeyofT>(obj:T, key:K):T[K] {return obj[key];}constperson= { name:"John", age:30 };console.log(getProperty(person,"name")); // Works// console.log(getProperty(person, "address")); // Error: "address" is not in type "{ name: string, age: number }"
In this example, the second type parameter K is constrained to be a key of the first type parameter T.
In this example, we create a Queue class that works with any type. When we instantiate it with new Queue<number>(), all the methods use the correct type.
Generic Class with Constraints
Like functions, classes can also use constraints on their type parameters:
TypeScript provides powerful ways to transform existing types into new ones:
Generic mapped types
// Make all properties optionaltypePartial<T> = { [PinkeyofT]?:T[P];};// Make all properties readonlytypeReadonly<T> = {readonly [PinkeyofT]:T[P];};interfaceUser { id:number; name:string; email:string;}// All properties are optionalconstpartialUser:Partial<User> = { name:"John",};// All properties are readonlyconstreadonlyUser:Readonly<User> = { id:1, name:"John", email:"john@example.com",};// readonlyUser.name = "Jane"; // Error: Cannot assign to 'name' because it is a read-only property
These are actually built-in utility types in TypeScript, but they're implemented using generics.
Conditional Types
Conditional types select a type based on a condition:
Conditional types
typeNonNullable<T> =Textendsnull|undefined?never:T;typeStringOrNumber=string|number|null|undefined;typeNonNullStringOrNumber=NonNullable<StringOrNumber>; // string | number
Type parameters in generics are conventionally represented by single capital letters, but you can use any valid identifier. Here are some common conventions:
T: Type (generic)
K: Key (often for objects)
V: Value (often paired with K)
E: Element (often for arrays/collections)
P: Property
R: Return type
S, U: Additional types when more than one type parameter is needed
Using descriptive names for complex generics can make the code more readable:
// Less type-safefunctiongetLast(arr:any[]):any {return arr[arr.length-1];}// Type-safe with genericsfunctiongetLast<T>(arr:T[]):T|undefined {returnarr.length? arr[arr.length-1] :undefined;}
2. Overconstraining generics
// Too restrictive - only works with objects containing id fieldfunctionfindById<Textends { id:number }>(items:T[], id:number):T|undefined {returnitems.find((item) =>item.id === id);}// More flexible - allows custom comparisonfunctionfindBy<T,KextendskeyofT>(items:T[], key:K, value:T[K]):T|undefined {returnitems.find((item) => item[key] === value);}
3. Not leveraging type inference
TypeScript can often infer the correct types:
// Don't do this (explicit types not needed)constresult=identity<string>("hello");// Do this (let TypeScript infer the type)constresult=identity("hello");
4. Using appropriate constraints
Ensure your generic constraints are appropriate:
// This allows accessing .length on any TfunctiongetLength<Textends { length:number }>(item:T):number {returnitem.length;}// More specific for arraysfunctionarrayLength<T>(arr:T[]):number {returnarr.length;}
5. Use meaningful generic type names
Use descriptive names for clarity in complex scenarios:
// Less readablefunctionprocess<T,U,V>(input:T,transform: (item:T) =>U,filter: (item:U) =>V):V {returnfilter(transform(input));}// More readablefunctionprocess<TInput,TIntermediate,TOutput>( input:TInput,transform: (item:TInput) =>TIntermediate,filter: (item:TIntermediate) =>TOutput):TOutput {returnfilter(transform(input));}