本文は探索的な学習ノートのガイドです。記事の内容のリズムに合わせて、忍耐強く、注意深く理解していくことを願っています。きっと何かを得られるでしょう!
最初は、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
の左側のこのユニオン型は、これは関数であり、その引数も関数であり、単一の引数であることを意味します。この引数arg
はnumber
である可能性も、boolean
である可能性もあります。
TypeScript の関数引数型は「逆変性」という特性を持つため、arg
はU1_1
とU1_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 の問題はここで一段落です。私たちが学んださまざまな特性をまとめてみましょう:
- 裸の型パラメータに対する条件型推論は分配的である。
- ユニオン型は、各サブアイテムを引数として関数のユニオンに変換し、逆変性の特性を用いて関数の交差型に変換することができる。
- 関数のオーバーロードの交差型は、等価なインターフェース形式に書き換えることができる。
- 複数の関数オーバーロードに対する条件判断推論を行うと、最後のオーバーロード形式だけがターゲットとして選ばれる。
最近、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 にマージされました。