こんにちは。デジスマチームでエンジニアをやっている堀田です。
一年くらい前に TypeScriptの型で麻雀の点数計算をするブログ を書きました。この中で、type-challenges というプロジェクトを紹介しました。これは「TypeScriptで、こういう型を定義しなさい」という問題が集まったプロジェクトなのですが、その中に 1042 IsNever の問題があります。
このブログでは、それに関して色々調査して結果をまとめました。
前提知識
IsNever ~導入編~
type Sample<T> = T
上のSample型はある型パラメータTを受け取って、Tをそのまま返す型です。TはGenericsですので、どのような型でも渡される可能性があります。
今回実現したいことは、「渡されたTがnever型かどうか確認する」ということです。
type IsNever<T> = T extends never ? true : false
このように書けば判定できるでしょうか?
never型には never型以外の全ての型はnever型に代入できない という特徴があります。
上の定義では T extends never
としているので、Tがneverであれば代入可能*1と判定されtrue型になりそうです。
いくつか型を渡してみましょう
// false type ParameterIsNever1 = IsNever<string> // false type ParameterIsNever2 = IsNever<number> // ? type ParameterIsNever3 = IsNever<never>
string型やnumber型を渡すとfalse型になるので期待通りです。
そして、最後の ParameterIsNever3型
は true型でもfalse型でも無く never型 になります。
IsNever = never
冒頭で定義した IsNever型では、Tのnever型判定ができないことが分かりました。
この件に関してTypeScript RepositoryのIssuesで質問している方が居て、それに対してコントリビューターの方が回答していました。
never型は空のunion型*2であり、空のunion型をdistributive conditional typeに渡しても適用されずneverになります。また、distributiveの性質をdisableすれば期待通りの挙動になります。
と言っています。
実際にchecker.tsのコードを読んで確認しました。
function getUnionType(types: readonly Type[], unionReduction: UnionReduction = UnionReduction.Literal, aliasSymbol?: Symbol, aliasTypeArguments?: readonly Type[], origin?: Type): Type { if (types.length === 0) { return neverType; } ...
まず、 getUnionType
関数 を見つけました。これは Type[]
を受け取って処理をする関数ですが、その一行目で types.length === 0
であれば neverType
を返すという処理が書かれています。コメントの通り、空のunion型をnever型として扱っているようです。
次に getConditionalTypeInstantiation
関数を見つけました。ここでconditional typeが最終的にどうなるのか決定しているようです。
その中にconsole.logを仕込んで動作確認しました。
... const checkType = root.checkType; console.log("root name is", root.aliasSymbol?.escapedName); console.log("root is distributive?", root.isDistributive); const distributionType = root.isDistributive ? getMappedType(checkType, newMapper) : undefined; // @ts-ignore console.log("distributionType", distributionType?.intrinsicName); // Distributive conditional types are distributed over union types. For example, when the // distributive conditional type T extends U ? X : Y is instantiated with A | B for T, the // result is (A extends U ? X : Y) | (B extends U ? X : Y). result = distributionType && checkType !== distributionType && distributionType.flags & (TypeFlags.Union | TypeFlags.Never) ? mapTypeWithAlias(getReducedType(distributionType), t => getConditionalType(root, prependTypeMapping(checkType, t, newMapper)), aliasSymbol, aliasTypeArguments) : getConditionalType(root, newMapper, aliasSymbol, aliasTypeArguments); root.instantiations!.set(id, result);
入力したtsファイルと出力結果をそれぞれ以下に示します。
// 入力 type IsNever<T> = T extends never ? true : false type Check = IsNever<never> // 出力 // root name is IsNever // root is distributive? true // distributionType never
distributiveな条件の下、never型を渡せていました。
次に、 result
に値を代入している箇所を見ます。すると一番最後の条件で (TypeFlags.Union | TypeFlags.Never)
のようにnever型のチェックをしているのが確認できます。
最終的に result
には distributionType
が never型であれば、never型がそのまま代入されるという処理になっていました。
const distributionType = root.isDistributive ? getMappedType(checkType, newMapper) : undefined;
元のコードを良く見ると、 上記のような判定がされているのが分かります。
実はこれが解決に繋がります。
IsNever ~解決編~
type IsNever<T> = [T] extends [never] ? true : false; // true type Check = IsNever<never>
これで判定できます。
そもそもの原因は、「neverは空のunionであるがdistributiveに解決しようとして、結果適用されない」ということでした。
conditional typeをdistributiveに適用しない方法が公式でも紹介されており、 []
で囲めばOKとのことなので上記のコードで解決できました。
function getTypeFromConditionalTypeNode(node) { ... isDistributive: !!(checkType.flags & 262144 /* TypeFlags.TypeParameter */), ...
checker.ts
では、 getTypeFromConditionalTypeNode
関数内でdistributiveなconditional typeかどうか決定しています。
T extends never
と書いた場合. =>checkType.flags
=TypeFlags.TypeParameter
(この時checkType = T
)[T] extends [never]
と書いた場合 =>checkType.flags
=TypeFlags.Object
(この時checkType = [T]
)
それぞれの書き方で checkType.flagsが変わるので、distributionを避けられるという仕組みのようです。
type IsNever1<T> = T[] extends never[] ? true : false type IsNever2<T> = {t: T} extends {t: never} ? true : false
結局、TypeFlags.TypeParameter
を避ければ良いので、上記のようなコードでも判定できそうです。*3
(実際、type-challengesのIsNever問題のチェックは全てパスできました。)
まとめ
type-challengesで見かけたIsNever型の紹介とその原理を調べました。 答え自体は簡単に書けますが、その背景を知るのはとても面白かったです。
参考
- https://github.com/microsoft/TypeScript/issues/31751
- https://stackoverflow.com/questions/60905518/why-are-typescript-arrays-covariant
- https://speakerdeck.com/saiya_moebius/type-system-of-the-typescript?slide=12
We are hiring!
デジスマをもっと知りたいと思ってくれた方はぜひ紹介資料をご覧下さい!
エムスリーでは、デジスマ以外のサービスもたくさんあります。
エンジニアに限らずデザイナーやプロダクトマネージャーも積極的に採用しています。
他のサービスにも興味を持たれた方は下記よりお問い合わせください。
*1:公式の説明ではthe type on the left of the extends is assignable to the one on the rightのように「assignable」と書かれており、このブログではそれを「代入可能」と訳しています。ちなみに、TypeScriptには型の互換性としてsubtypeとassignmentの2つ があります。
*2:never型が全ての型のサブタイプだったり、空のunionであるのはBottom型の特徴です。実際never型はTypeScriptのBottom型です
*3:TypeScriptの配列には共変性があります