As is so often emphasized in my articles, facilitating reuse is perhaps one of the most import aspects of system design. For over a decade, languages such as Java, and C# have used generics as one of the primary mechanisms for reuse (I recall first hearing about them in C# back in 2005). Fortunately, Generics have been a key feature of TypeScript
since the very beginning.
This article aims to briefly illustrate some of the main benefits of leveraging Generics
in TypeScript to simplify implementations and maximize reuse.
What Are Generics?
Typescript Generics are a language feature that allow for providing reusable implementations based on type deferment. This means you can create a function, interface, or class with a placeholder for a type, thus affording the flexibility to use any type without losing the safety and robustness of type checking.
With Generics, types can be defined as an abstraction such that the types can be specified by clients based on their context. This affords the ability to allow implementations to adapt to the types specified while still ensuring type integrity is preserved.
Using Generics
Generics are defined within angle brackets <>
, typically specified as “T” (for “Type”), where “T” is simply an arbitrary placeholder name which is used to denote and reference the type
.
For example, consider the following function:
1 2 3 4 5 | const log = (arg: string) => { console.log(arg); }; |
Using Generics, we can implement the function to accept a Generic Type rather than a fixed type:
1 2 3 4 5 | const log<T>(arg: T) => { console.log(arg); }; |
In the above example, we define the generic within angle brackets, and reference the Type via the “T” value passed to the generic. Now, rather than only allowing strings to be logged, any type can be provided.
We can then use the generic by simply specifying the type when we invoke the function:
1 2 3 | log<string>('some value'); // 'some value' |
Note that in the above, we explicitly set the type to string
; however, this isn’t necessary when the type can be inferred, in which case the type can be omitted if preferred:
1 2 3 | log('some value'); // 'some value' |
We can also pass any type to the function’s generic as well:
1 2 3 4 5 6 | type User = { id: number; } log<User>({id: 123}); // {id: 123} |
Generics are not limited to functions. They can also be applied to interfaces and classes as well:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | interface Identifier<T> { getId(): T } class Id<T> implements Identifier<T> { private _id: T; constructor(id: T) { this._id = id; } public getId(): T { return this._id; }; } console.log( new Id<number>(123).getId() ); // 123 console.log( new Id<string>('123').getId() ); // "123" |
As can be seen in the above, just as with functions, we can define Generics for interfaces and classes using the same syntax.
Advanced Generics
TypeScript’s Generics also support more advanced features such as constraints, default types, and using multiple types, allowing for more precise and robust implementations.
Using Constraints
Using constraints in Generics allows for specifying requirements for the generic types. For instance, we can define a function which only operates on types that have a certain property or method.
Consider a getLength
function which only accepts arguments that have a length
property, ensuring the argument is array-like or a string:
1 2 3 4 5 6 7 | const getLength = <T extends {length: number}>(val: T): number => ( val && val.length ); console.log(getLength('test')); // "test" console.log(getLength([1,2,3])); // [1,2,3] |
Default Types
Default types in generics offer a way to specify a fallback type to be used when a type is not explicitly provided. This is quite useful for providing default implementations, while still affording the flexibility of allowing clients to override the default type.
For example, we can create a generic getData
function that fetches data and returns a response of the provided type, or fallback to a default type if not provided:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // default type ... type Response = { results: string[] } const getData = async<T = Response>(url: string): Promise<T> => { const response = await fetch(url); return response.json(); } ... // use the default type ... const result = await getData('https://somedomain.com/api'); ... // provide an explicit type ... type Results = { items: string[], totalCount: number } const result = await getData<Results>('https://somedomain.com/api/v2'); |
Using Multiple Types
Multiple types can also be used in Generics, allowing for creating functions, interfaces, or classes that can handle more than one type of input, and / or return more than one type of output, offering greater flexibility and precision.
For instance, consider a simple merge function which combines two different objects into one, while preserving their distinct types:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | const merge = <T, U>(a: T, b: U): T & U => ( {...a, ...b} ); merge({ title: 'Title' }, { url: 'https://somedomain.com' }); // or ... type Link = { url: string } type Info = { description: string; } merge<Info, Link>({ description: '...' }, { url: 'https://somedomain.com' }); |
We can also expand on this to include constraints to ensure the merge
function only accepts parameters that are object-like:
1 2 3 4 5 6 7 8 9 10 11 | const merge = <T extends object, U extends object>(a: T, b: U): T & U => ( {...a, ...b} ); merge( {firstname: 'John'}, {lastname: 'Doe'} ); merge('123', 123); // error: Argument of type 'string' is not assignable to parameter of type 'object'. |
Concluding Thoughts
TypeScript Generics offer a powerful way to build flexible, reusable, and type-safe solutions. By abstracting over types, Generics allow for providing implementations which can operate on a variety of data types, resulting in increased scalability and maintainability.