Introduction to Generics
Generics can be understood as passing types as variables to type definitions, similar to passing parameters to functions. For example:
function identity<Type>(arg: Type): Type {
return arg
}
let output = identity<string>('myString')
By using <>
to enclose Type
, we can pass the type into the generic function. The effect of the above function is to accept an argument of type Type
and return a result of type Type
.
Since Type
is a parameter, its name can be freely chosen. It is common to use T
as the parameter name, so it can be written as:
function identity<T>(arg: T): T {
return arg
}
Type Inference
When using a generic function, it is not necessary to explicitly specify the type T
(and we usually don't). In this case, TypeScript will automatically infer the type of T
:
let output = identity('myString')
// output is also of type string
In the above example, if the type <string>
is not explicitly specified, TypeScript will directly infer the type of "myString"
as T
, so the function will return a string.
Setting Boundaries
By default, generics can be any type, which can lead to low readability. Additionally, when operating or calling methods on a "genericized" type, it cannot pass type checks because it is of any type. To solve this problem, we can set boundaries for generics using extends
.
interface Lengthwise {
length: number
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length) // arg must have a length property, checked by type
return arg
}
In the above code, <T extends Lengthwise>
indicates that T
must be a type with a length
property. Any type with a length
property satisfies the requirements of this generic, such as an array.
Binding Abilities
According to the official website, generics are used for type reuse, which is indeed very effective as shown in the simple introduction above. However, besides type reuse, what other applications do generics have?
My answer is type linkage. T can bind to other generics used within the same type definition.
Let's take another look at this example, which actually binds the input type and the output type:
function identity<Type>(arg: Type): Type {
return arg
}
Now let's look at a more obvious example of "type binding".
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key]
}
let x = { a: 1, b: 2, c: 3, d: 4 }
getProperty(x, 'a') // valid
getProperty(x, 'm') // error, because `Key` is bound to the key of `Type`, and 'm' is not a key of `Type`
Mapped Types
const myMap = {
a: () => {},
b: (someString: string) => {},
c: (someNumber: number) => {},
}
type MyMap = typeof myMap
type MyKey = keyof MyMap
Suppose we have an object with keys 'a', 'b', and 'c', and the values are different functions. Now we need to obtain the type of an object with keys and corresponding function parameters. How can we achieve this?
type Answer = Record<MyKey, Parameters<MyMap[MyKey]>>
If we write it like this, it will be problematic. Answer
is just an object with keys of MyKey
and values of Parameters<MyMap[MyKey]>
, but the relationship defined by myMap
is lost, resulting in:
type Answer = {
a: [] | [someString: string] | [someNumber: number]
b: [] | [someString: string] | [someNumber: number]
c: [] | [someString: string] | [someNumber: number]
}
So at this point, we need to use the binding ability of generics to establish a fixed relationship between the key and the value.
type Answer2 = {
[K in MyKey]: Parameters<MyMap[K]>
}
K
is the key of the type myMap
, and Answer2
must have a value that is the parameter of MyMap[K]
. This way, the key and value are bound together.
In the latest version, there is even a fancy way to as
the property type again:
type Getters<Type> = {
[Property in keyof Type as `get${Capitalize<
string & Property
>}`]: () => Type[Property]
}
interface Person {
name: string
age: number
location: string
}
type LazyPerson = Getters<Person>
The output is as follows:
type LazyPerson = {
getName: () => string
getAge: () => number
getLocation: () => string
}
P.S. What is as
? The official documentation refers to it as Type Assertions, used to assert one type as another type. However, these two types can be as
to a smaller or larger extent, but one must be a subset of the other. In the example of LazyPerson
, since it is ultimately all string
, as
can be used.
Practical Example
Below is a question. If you are interested, you can think about how to improve the types of the following JS function. (The answer is below, so don't scroll down yet)
Question
First, we have a myMap
, but instead of using it directly, we wrap it with a wrapper
function. This allows us to perform certain pre-processing before running the function. The question is, how should the type of wrappedMap
be written?
const myMap = {
a: () => {},
b: (someString) => {},
c: (someNumber) => {},
}
function wrapper(_key, fn) {
return async function (...arg) {
// do something
await Promise.resolve()
fn.apply(null, arg)
}
}
const wrappedMap = {}
for (const key in myMap) {
const k = key
wrappedMap[k] = wrapper(k, myMap[k])
}
Answer
const myMap = {
a: () => {},
b: (someString: string) => {},
c: (someNumber: number) => {},
}
type MyMap = typeof myMap
type MyKey = keyof MyMap
function wrapper<K extends MyKey, T extends MyMap[K]>(_key: K, fn: T) {
return async function (...arg: Parameters<T>) {
await Promise.resolve()
;(fn as any).apply(null, arg)
}
}
type WrappedMap = {
[K in MyKey]: ReturnType<typeof wrapper<K, MyMap[K]>>
}
const wrappedMap: Partial<WrappedMap> = {}
for (const key in myMap) {
const k = key as MyKey
wrappedMap[k] = wrapper(k, myMap[k])
}
Now the type of WrappedMap
is indeed the result of the wrapper function, but why do we still need to set (fn as any)
?
Why do we need to set fn as any?
Because for TS, a
, b
, and c
are not bound to the parameter types of their values, so even if T
is used for restriction, it has no effect. This statement may sound a bit convoluted, but the answer 2 below may be clearer.
Answer 2
const myMap: MyMap = {
a: () => {},
b: (someString: string) => {},
c: (someNumber: number) => {},
}
interface MyMapArgs {
a: []
b: [someString: string]
c: [someNumber: number]
}
type MyMap = {
[K in keyof MyMapArgs]: (...args: MyMapArgs[K]) => void
}
type MyKey = keyof MyMap
function wrapper<K extends MyKey, F extends MyMap[K]>(_key: K, fn: F) {
return async function (...arg: Parameters<F>) {
await Promise.resolve()
fn.apply(null, arg)
}
}
type WrappedMay = {
[K in MyKey]: ReturnType<typeof wrapper<K, MyMap[K]>>
}
const wrappedMap: Partial<WrappedMay> = {}
for (const key in myMap) {
const k = key as MyKey
wrappedMap[k] = wrapper(k, myMap[k])
}
The solution without (fn as any)
is to create another map to map the things you need to associate separately, which is the MyMapArgs
above. Then use this mapping to create MyMap
, so that TS finally understands that these two things are related.
P.S. For more detailed information, please refer to issues#30581 and pull#47109