banner
沈青川

旧巷馆子

愿我如长风,渡君行万里。
twitter
jike

🤸🏻 タイプ体操とその実戦応用についての記録

Avatar

本文は探索的な学習ノートのガイドです。記事の内容のリズムに合わせて、忍耐強く、注意深く理解していくことを願っています。きっと何かを得られるでしょう!

最初は、TS の一般的な問題「Union to Tuple」を学びたかっただけです。"a" | "b"のようなユニオン型(Union type)を、対応する["a", "b"]のようなタプル(Tuple)に変換するという問題です。この問題は実際にはかなり包括的です。どうやって解決するか見てみましょう。

inferをツールとして使うことに慣れている方は、条件型推論(conditional type infer)を次のように行うことを自然に思いつくかもしれません:

type UnionToTuple<Union> = Union extends infer A | infer B ? [A, B] : never;
type IncorrectTuple = UnionToTuple<"a" | "b">;

結果は私たちが思っていたものとは異なり、このIncorrectTupleの結果は["a", "a"] | ["b" | "b"]です。これは、TS において「裸の型パラメータに対して条件型推論を行う」ことが「分配的(distributive)」に設計されているためです。つまり、extendsの左側がユニオン型であれば、TS はextends演算子の左側のユニオン型を分解して判断を行います。したがって、実際には'a' extends ...b extends ...を行っているため、このアプローチはうまくいかないようです。

この問題を解決するには、他の「テクニカルな変換」を使う必要があります。あるいは、特定の TS の特性を利用することもできます。これらのテクニックは「知識的内容」に属し、すでに学んだ知識から導き出されたものではありません。したがって、自分の「体操能力」を高めるためには、多くの例を読み、吸収することで十分な経験を積む必要があります。

私たちが達成しようとしていることをよく考えると、ユニオン型の各サブアイテムをタプルに構築する必要があるので、最初のステップは上の例から"a""b"を取り出すことです。

次の例 1 を見てみましょう:

type U1_1 = (a: number) => void
type U1_2 = (a: boolean) => void

type U1 = ((arg: U1_1) => void) | ((arg: U1_2) => void)

type TEST = U1 extends (arg: infer I) => void ? I : never

// TEST: ((a: number) => void) & ((a: boolean) => void)

私たちは驚くべきことに、ユニオン型に対して条件型推論を行った後、交差型を得ることができることを発見しました!この計算は何を意味するのでしょうか?解読してみましょう:

extendsの左側のこのユニオン型は、これは関数であり、その引数も関数であり、単一の引数であることを意味します。この引数argnumberである可能性も、booleanである可能性もあります。

TypeScript の関数引数型は「逆変性」という特性を持つため、argU1_1U1_2の交差型である必要があります。すなわち、U1_1 & U1_2です。この結論の理由が不明な場合は、リンク内の記事を読み、共変性と逆変性の概念を整理してください。

私たちは、"a" | "b"((arg: (a: "a") => void) => void) | ((arg: (a: "b") => void) => void)に構築するためのヘルパー型を作成し、その後交差型に変換することができます:

type UnionToFnUnions<U> = U extends any
	// ほとんどすべての型がextends anyであるため、
	// ここではUを使って任意に構築できます
	? (k: (x: U) => void) => void
	: never

type UnionToFnIntersections<U> = UnionToFnUnions<U> extends (arg: infer I) => void ? I : never

次に例 2 を見て、TS の特性を理解しましょう:

type T1 = (
  ((arg1: number) => void) 
  & ((arg1: boolean) => void)
)

interface T2 {
  (arg1: number): void
  (arg1: boolean): void
}

type T3 = T1 extends T2 ? true : false
//   ^? true

私たちは次の結論を得ました:TS では、異なる署名を持つ複数の関数型の交差型は、複数のオーバーロードされた関数インターフェース型と等価です。このインターフェース型に対して次のようなinfer推論を行うことができます:

type FnIntersectionToTuple<T> = T extends {
    (x: infer A): void;
    (x: infer B): void;
} ? [A, B] : never;

ついに道筋が見えてきました!私たちは最終的な期待される結果を得るための道を見つけました。しかし、これは完璧ではないようです。最終的に得られるタプルの要素数は、私たちが書いたinferの数に完全に依存しています!

すべての関数のオーバーロードを「ループ」して読み取る方法はあるのでしょうか?

次の例を見てみましょう:

type F1 = (arg: "a") => void
type F2 = (arg: "b") => void
type F3 = (arg: "c") => void

type F_TEST1 = (F1 & F2 & F3) extends { (a: infer R): void; } ? R : never;
// ^? "c"

おや!どうして"c"だけを取得したのでしょうか?"a""b"はどこに行ったのでしょうか?心配しないでください。これは TypeScript の設計によるものです。これを TS の「特性」として、体操のツールとして使えば良いのです!

私たちが得た結論は:TS では、複数の関数オーバーロードに対して条件判断infer推論を行うと、実際には最後のオーバーロード形式だけがターゲットになります。

上記の例で学んだ特性を、先ほど推論した関数の交差型に統合し、ヘルパー型を作成します:

type IntersectionPop<U> = UnionToFnIntersections<U> extends { (a: infer A): void; } ? A : never;

これでユニオン内の各アイテムを取得できました。次に、最終的なタプルにそれらを挿入するためのヘルパー型を作成する必要があります:

type Prepend<U, T extends any[]> =
	((a: U, ...r: T) => void) extends (...r: infer R) => void ? R : never;

素晴らしい!すべての準備が整いました。では、最終的な型計算式を作成しましょう:

type UnionToTupleRecursively<Union, Result extends any[]> = {
  1: Result;
  0: UnionToTupleRecursively<
	   Exclude<Union, IntersectionPop<Union>>, // 再帰的に既にポップされた項を除外
       Prepend<IntersectionPop<Union>, Result> // ポップされたものを結果に追加
     >
}[[Union] extends [never] ? 1 : 0];

type UnionToTuple<U> = UnionToTupleRecursively<U, []>;

実際、この問題はここで終了ですが、もしあなたが私のように少し強迫観念があるなら、このUnionToTupleを試すと、次のような欠陥があることに気づくでしょう:

type FINAL_1 = UnionToTupleRecursively<"a" | "b", []>
//   ^? [a: "a", a: "b"]

得られたこのFINAL_1はラベル付きのタプルです。これはその性質に影響を与えませんが、["a", "b"]と使用上は何の違いもありません。しかし、見た目が少し奇妙です。このラベルを取り除くために、次のような変換を追加することを検討できます:

type RemoveLabels<Tuple, Result extends any[]> = Tuple extends [infer E, ...infer Rest]
  ? RemoveLabels<Rest, [...Result, E]>
  : Result

type UnionToTuple<U> = RemoveLabels<UnionToTupleRecursively<U, []>, []>;

type FINAL_2 = UnionToTupleRecursively<"a" | "b", []>
//   ^? ["a", "b"]

Union to Tuple の問題はここで一段落です。私たちが学んださまざまな特性をまとめてみましょう:

  1. 裸の型パラメータに対する条件型推論は分配的である。
  2. ユニオン型は、各サブアイテムを引数として関数のユニオンに変換し、逆変性の特性を用いて関数の交差型に変換することができる。
  3. 関数のオーバーロードの交差型は、等価なインターフェース形式に書き換えることができる。
  4. 複数の関数オーバーロードに対する条件判断推論を行うと、最後のオーバーロード形式だけがターゲットとして選ばれる。

最近、Volar チームは Vue の emits のために LSP サービスの型を作成する際に難題に直面しました。問題の概要を次のようにまとめました:

Vue のdefineEmitsは、ユーザーに型パラメータを指定させ、その型パラメータに基づいて、(event: 'foo', arg: T1) => void & (event: 'bar', arg1: T2, arg2: T3) => voidのような型を返す必要があります。

現在、返された交差型からすべての「最初の引数」の型を抽出し、ユニオンを作成する必要があります。

私たちは簡単に推測できますが、最終的な型計算式はおそらく上記のような再帰的なもので、最終的にはタプルを得ることになります。ユニオンからタプルを得るのは難しいですが、タプルからユニオンを得るのは簡単です。Tuple[number]を使えばいいのです:

type ExtractFirstArgRecursively<I, Result extends any[]> = {
  1: Result;
  0: ExtractFirstArgRecursively<
       NarrowIntersection<I, PopIntersectionFuncs<I>>,
       Prepend<GetIntersectionFuncsLastOneFirstArg<I>, Result>
     >
}[[I] extends [never] ? 1 : 0];

次に、上記の中間計算を行うヘルパー型を実装します:

// 上記の結論4を利用
type PopIntersectionFuncs<I> = I extends (...args: infer A) => infer R ? (...args: A) => R : never

type GetIntersectionFuncsLastOneFirstArg<I> = I extends (firstArg: infer F, ...rest: infer P) => void ? F : never

NarrowIntersectionという型には少し注意が必要です。最初は次のように書いていました:

type NarrowIntersection<I, T> = I extends (T & infer R) ? R : never

しかし、こうすると最終的に得られるタプルに常に余分なneverが含まれてしまいます。これは、Narrow の際に問題が発生したと考え、手動で中間ステップを記述しました:

type E1 = ((event: 'foo', arg: number) => void)
type E2 = ((event: 'bar', arg1: string, arg2: number) => void)
type E3 = ((event: 'fee', arg: boolean) => void)

type TT = (E1 & E2 & E3)

type Last1 = PopIntersectionFuncs<TT>
type NTT1 = NarrowIntersection<TT, Last1>
//   ^? ((event: 'foo', arg: number) => void) & ((event: 'bar', arg1: string, arg2: number) => void)
//   Result = ["fee"]

type Last2 = PopIntersectionFuncs<NTT1>
type NTT2 = NarrowIntersection<NTT1, Last2>
//   ^? (event: 'foo', arg: number) => void
//   Result = ["bar", "fee"]

type Last3 = PopIntersectionFuncs<NTT2>
type NTT3 = NarrowIntersection<NTT2, Last3>
//   ^? unknown
//   Result = ["foo", "bar", "fee"]

type Last4 = PopIntersectionFuncs<NTT3>
type NTT5 = NarrowIntersection<NTT3, Last4>
//   ^? never ---> 再帰を終了し、次のラウンドはない
//   Result = [never, "foo", "bar", "fee"]

結局のところ、このunknownが問題を引き起こしていました。私たちは、オンラインから便利なツール型IsUnknownを引用してこの問題を解決します:

type IsAny<T> = 0 extends 1 & T ? true : false
type IsNever<T> = [T] extends [never] ? true : false
type IsUnknown<T> = IsAny<T> extends true
  ? false
  : unknown extends T
	? true
	: false

type NarrowIntersection<I, T> = I extends (T & infer R)
  ? IsUnknown<R> extends true
     ? never
     : R
  : never

素晴らしい、これで Volar の問題に対する最終的な解答が得られました:

type GetAllOverloadsFirstArg<I> = RemoveLabels<ExtractFirstArgRecursively<I, []>, []>

type FINAL = GetAllOverloadsFirstArg<TT>
// ["foo", "bar", "fee"]

この変更に関するプルリクエストの詳細はこちらをご覧ください。コードは Volar Vue v1.8.4 にマージされました。

参考#

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