SSShooter

SSShooter

Write like you're running out of time.

TypeScript Generic Parsing

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. We often use T as the parameter name, so it can be written as follows:

function identity<T>(arg: T): T {
  return arg
}

Type Inference#

When using a generic function, you can omit specifying the type T (and we usually do), and 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 as well.

Setting Boundaries#

By default, generics can be any type, which reduces readability. When operating or calling methods on a "generic-imposed" type, it cannot pass the type check because it is of any type. To solve this problem, you 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 the length property, passing the type check
  return arg
}

In the above code, <T extends Lengthwise> indicates that T must be a type with the length property. Any type with the length property satisfies the requirement 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. But besides type reuse, what other applications does generics have?

My answer is type linkage. T can bind to other generics used within the same type definition.

Let's take a look at the previous example again, 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 there is an object with keys 'a', 'b', and 'c', and the values are different functions. Now we need to get 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 incorrect. Answer is just an object with keys of MyKey and values of Parameters<MyMap[MyKey]>, but it loses the relationship defined by myMap, and becomes like this:

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! The correct answer is as follows:

type Answer2 = {
  [K in MyKey]: Parameters<MyMap[K]>
}

K is the key of the type myMap, and Answer2 must have the value of Parameters<MyMap[K]>. This binds the key and the value in a fixed relationship.

Even in the newer versions, there is 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, which is 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 they are all string at the end, as can be used.

Practical Application#

Below is a question. If you are interested, you can think about how to improve the type of the following JS function. (The answer is below, don't scroll down yet!)

Question#

First, we have a myMap, but we don't use it directly. Instead, we wrap it with wrapper so that we can perform some pre-processing before running the function. The question is, how do we write the type of wrappedMap?

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, c are not bound to the parameter types of their values at all. Even if T is used to restrict them, it has no effect. This statement may sound a bit convoluted, but the answer 2 below may make it 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 to removing (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

Link: https://ssshooter.com/typescript-generics/

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.