エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

TypeScript never型の判定

こんにちは。デジスマチームでエンジニアをやっている堀田です。

一年くらい前に 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で質問している方が居て、それに対してコントリビューターの方が回答していました。

https://github.com/microsoft/TypeScript/issues/31751#issuecomment-498526919

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型の紹介とその原理を調べました。 答え自体は簡単に書けますが、その背景を知るのはとても面白かったです。

参考

We are hiring!

デジスマをもっと知りたいと思ってくれた方はぜひ紹介資料をご覧下さい!

speakerdeck.com

エムスリーでは、デジスマ以外のサービスもたくさんあります。
エンジニアに限らずデザイナーやプロダクトマネージャーも積極的に採用しています。 他のサービスにも興味を持たれた方は下記よりお問い合わせください。

jobs.m3.com

*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の配列には共変性があります