SSShooter

SSShooter

Write like you're running out of time.

TypeScript ジェネリック解析

ジェネリクス入門#

ジェネリクスは簡単に言うと型を変数として型定義に渡すことができ、関数に引数を渡すのと同じです。例えば:

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

let output = identity<string>('myString')

<>Type を囲むことで、型をジェネリクス関数に渡すことができます。上記の関数の効果は:型 Type の引数を受け取り、型 Type の結果を返します。

Type が引数である以上、名前は自由に付けられます。私たちはよく T を引数の名前として使うので、こう書くこともできます:

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

自己推論#

ジェネリクス関数を使用する際、T の型を明示的に指定する必要はありません(通常はそうします)。この場合、TS は自動的に T の型を推論します:

let output = identity('myString')
// output も string

上の例のように、型 <string> を明示的に指定しない場合、TS は "myString" の型を T として推論するため、関数が返すのも文字列になります。

制約を設ける#

デフォルトでは、ジェネリクスは任意の型を受け入れるため、可読性が低くなります。また、「ジェネリクスを適用した」型に対して操作やメソッドを呼び出す際、任意の型であるため、型チェックを通過できません。この問題を解決するために、extends を使用してジェネリクスに制約を設け、型を限定することができます。

interface Lengthwise {
  length: number
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length) // arg は必ず length プロパティを持つため、型チェックを通過
  return arg
}

上のコードでは <T extends Lengthwise> により、この Tlength プロパティを持つ型でなければならないことを示しています。length プロパティを持つ任意の型がこのジェネリクスの要件を満たします。例えば、配列も該当します。

バインディング能力#

公式サイトによると、ジェネリクスは型の再利用に使用されます。上記の簡単な紹介を経て、これは非常に効果的であると感じるでしょう。しかし、ジェネリクスは型の再利用以外にどのような用途があるのでしょうか?

私の答えは、型の連動です。T は同じ型定義内で使用される他のジェネリクスにバインドすることができます

もう一度この例を見てみましょう。実際には、入力の型と出力の型をバインドしています:

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

次に、「型バインディング」の遊び方がより目立つ例を見てみましょう。

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') // 可能
getProperty(x, 'm') // エラー、`Key` は `Type` のキーにバインドされており、m は `Type` のキーではない

マッピング型#

const myMap = {
  a: () => {},
  b: (someString: string) => {},
  c: (someNumber: number) => {},
}
type MyMap = typeof myMap
type MyKey = keyof MyMap

仮に、キーが a、b、c で、値が異なる関数のオブジェクトがあるとします。今、キーと対応する関数の引数のオブジェクトの型を得たい場合、どう実現すればよいでしょうか?

type Answer = Record<MyKey, Parameters<MyMap[MyKey]>>

こう書くと問題が発生します。Answer は単にキーが MyKey で、値が Parameters<MyMap[MyKey]> のオブジェクトですが、これらの間で myMap 定義の関係が失われ、次のようになります:

type Answer = {
  a: [] | [someString: string] | [someNumber: number]
  b: [] | [someString: string] | [someNumber: number]
  c: [] | [someString: string] | [someNumber: number]
}

このため、実際にはジェネリクスの型バインディング能力を使用する必要があります!正しい答えは次の通りです:

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

K は型 myMap のキーであり、Answer2 の値は必ず MyMap[K] の引数でなければなりません。これにより、キーと値の固定関係がバインドされます

さらに新しいバージョンでは、属性の型を再度 as することもできます:

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>

出力結果は次の通りです:

type LazyPerson = {
  getName: () => string
  getAge: () => number
  getLocation: () => string
}

P.S. as とは何でしょうか?公式文書では Type Assertions と呼ばれ、ある型を別の型として扱うために使用されますが、これらの型は小さく as することも、大きく as することもできます。ただし、片方はもう片方のサブセットでなければなりません。LazyPerson の例では、結局全てが string であるため、as を使用できます。

実戦#

まずは問題を提示します。興味があれば、以下の JS 関数の型をどのように改善するか考えてみてください。(答えは下にありますので、先に下に行かないでくださいね)

問題#

まず、myMap があり、それを直接使用するのではなく、wrapper でラップします。これにより、関数を実行する際に前処理を行うことができます。さて、wrappedMap の型はどう書けばよいでしょうか?

const myMap = {
  a: () => {},
  b: (someString) => {},
  c: (someNumber) => {},
}

function wrapper(_key, fn) {
  return async function (...arg) {
    // 何かをする
    await Promise.resolve()
    fn.apply(null, arg)
  }
}

const wrappedMap = {}
for (const key in myMap) {
  const k = key
  wrappedMap[k] = wrapper(k, myMap[k])
}

答え#

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])
}

確かに、WrappedMap の型が wrapper の返り値の効果を持つようになりましたが、この (fn as any).apply(null, arg) の行は、少し突飛に見えませんか?

なぜ fn を any にする必要があるのでしょうか?

TS にとって、abc はその値の引数型とまったく関連付けられていないため、T を制限しても効果がありません。この文は少し難解かもしれませんが、次の答え 2 を見れば、より明確になるかもしれません。

答え 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])
}

(fn as any) を取り除く解決策は、別のマップを作成して必要な関連付けを行うことです。これが上記の MyMapArgs です。次に、このマッピングを使って MyMap を作成することで、TS はついにこれら二つのものが関連していることを理解します。

P.S. より詳細な情報は issues#30581pull#47109 を参照してください。

リンク: https://ssshooter.com/typescript-generics/

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。