🕵️

【TypeScript】[1, 'a', 2] はどうして (string | number)[] になるの?

に公開

最近社内で Effective TypeScript 第2版 を輪読しているのですが、型推論の話や型の拡大(widening)についての説明を読んでいて、「普段当たり前に使っている型推論ってどう動いているんだっけ?」とふと思い立ちました。

TypeScriptコンパイラのネイティブ実装のニュースも記憶に新しいですが、気になったことは調べてみよう🕵️‍♀️ という気持ちで、TypeScriptの心臓部である checker.ts の世界へ旅立つことにしました。

今回のゴールは、以下のコードがどのように (string | number)[] と解釈されるかを調べることです。

const v = [1, 'a', 2]

(const v = 1 だと簡単すぎるかも、と思って型が混在する配列を題材にしてみましたが、どのみち長大なコードを読むことになったのであまり関係ありませんでした 🫠)

なお、今回対象としたのは TypeScript 5.8.3 です。

旅のはじまり: コードを「木」として理解する AST

コンパイラは、私たちが書いたソースコードをまず解析し、抽象構文木(AST: Abstract Syntax Tree) というデータ構造に変換します。
この木構造は、コードの構文的な構造を表現しており、各ノードがプログラムの構成要素(変数宣言、関数呼び出しなど)を示しています。

ASTは自前のスクリプトでも確認できますが、今回はオンラインツールの TypeScript AST Viewer を使ってみます。
例のコードを入力すると、以下のようなASTが生成されます。

SourceFile {
  statements: [
    VariableStatement {
      declarationList: VariableDeclarationList {
        declarations: [
          VariableDeclaration {
            name: Identifier {
              escapedText: "v"
            },
            initializer: ArrayLiteralExpression {
              elements: [
                NumericLiteral { text: "1" },
                StringLiteral { text: "a" },
                NumericLiteral { text: "2" }
              ]
            }
          }
        ]
      }
    }
    // 今回は1行だけですが、他の変数宣言や関数定義などもあれば statements に追加されていきます。
  ],
  endOfFileToken: EndOfFileToken {}
}

「お!この時点である程度構造化されている!」というのが私の最初の感想でした。

このASTの構造は、以下のような階層になっています。

  • SourceFile: ファイル全体を表します。
  • VariableStatement: const v = [1, 'a', 2]; という文全体です。FunctionStatement などたくさんの種類があります。
  • VariableDeclaration: v = [1, 'a', 2] の部分です。
  • Identifier: 変数名 v を表します。
  • ArrayLiteralExpression: [1, 'a', 2] の部分です。この中の elements が配列の各要素を表します。

型チェックの旅路

はじまりの地 checkSourceFile から始まるバケツリレー

型チェックのプロセスは、このASTのトップレベルである SourceFile を処理する checkSourceFile 関数から始まります。
checkSourceFile は内部で checkSourceFileWorker を呼び出し、その中で statements 配列をループ処理していきます。

// checker.ts
function checkSourceFileWorker(node: SourceFile) {
  // ...

  // checkSourceElement の中では checkSourceElementWorker が呼ばれます。
  forEach(node.statements, checkSourceElement);
  // ...
}
// checker.ts
// checkSourceElementWorker は各ノード(Statement や Expression など)
// の種類に応じて、さらに専門のチェック関数に処理をバケツリレーのように渡していきます。
// 今回の例では、`statements` の最初の要素は `VariableStatement` であるため、
// `checkVariableStatement` 関数が呼び出されることになります。
function checkSourceElementWorker(node: Node): void {
  // ...
  switch (kind) {
    // ...
    case SyntaxKind.VariableStatement:
      return checkVariableStatement(node as VariableStatement);
    // ...
  }
}

VariableStatement 型のオブジェクトは declarationList プロパティを持っており、さらに checkVariableDeclarationList 関数に渡されます。

checkVariableDeclarationList では、受け取った declarationList の declarations プロパティをループ処理しながら、再び checkSourceElement 関数へ渡すことになります。

つまり、こう。

  1. checkSourceFile
  2. checkSourceFileWorker
  3. checkSourceElement
  4. checkSourceElementWorker
  5. checkVariableStatement
  6. checkVariableDeclarationList
  7. checkSourceElement (ここでの引数は VariableDeclaration オブジェクト)
  8. checkSourceElementWorker

checkSourceElementWorker はノードの種類に応じて適切なチェック関数を呼び出す関数でしたね。
例にもれず、 VariableDeclaration の場合は checkVariableDeclaration 関数が呼び出されることになります。

ということで、

  1. checkVariableDeclaration

になります。

checkVariableDeclaration では、変数宣言の初期化子(initializer)をチェックします。
初期化子は [1, 'a', 2] の部分のことで、この型を調べることで変数 v の型を推論します。

本丸 checkExpression と配列リテラルの型決定

checkVariableDeclaration からの処理は非常に長く複雑ですが(断腸の思いで割愛します!)、なんやかんや処理した結果、型チェックの司令塔である checkExpression 関数にたどり着きます。
(checkExpression の中で checkExpressionWorker が呼ばれます)

checkExpression は、あらゆる「式(Expression)」のASTノードを受け取り、その型を計算して返す、型チェッカーの心臓部です。

checkExpression は内部で checkExpressionWorker を呼び出しており、ここでノードの種類に応じた処理が行われます。
今回お題としている [1, 'a', 2] の配列は ArrayLiteralExpression オブジェクトなので、checkArrayLiteral 関数が呼び出されます。

// checker.ts
function checkExpressionWorker(node: Expression ...): Type {
  switch (kind) {
    // ...
    case SyntaxKind.ArrayLiteralExpression:
        return checkArrayLiteral(node as ArrayLiteralExpression, ...);
    // ...
  }
}

各要素のチェックと「型の拡大(Widening)」

冒頭に出たASTの結果にある elements を思い出してみましょう。こんな構造でしたね。

ArrayLiteralExpression {
  elements: [
    NumericLiteral { text: "1" },
    StringLiteral { text: "a" },
    NumericLiteral { text: "2" }
  ]
}

checkArrayLiteral 関数は、elements 配列の各要素を一つずつループ処理し、それぞれの型を決定していきます。

// checker.ts
function checkArrayLiteral(node: ArrayLiteralExpression, ...): Type {
  // ...
  const elementTypes: Type[] = [];
  // ...
  for (let i = 0; i < elementCount; i++) {
    const e = elements[i];
    // この中で再び checkExpression が呼び出される
    const type = checkExpressionForMutableLocation(e, checkMode, forceTuple);
    elementTypes.push(type);
    // ...
  }
}

このループの中で、elements の各要素が checkExpressionForMutableLocation 関数に渡され、 checkExpression が呼ばれることになります。
この関数が、型の拡大(Widening)の鍵を握っています。

checkExpression が呼ばれたらセットで checkExpressionWorker がついてきます。
そして NumericLiteral や StringLiteral に対する変換処理が実行されます。

// checker.ts
function checkExpressionWorker(node: Expression ...): Type {
  switch (kind) {
    // ...
    case SyntaxKind.StringLiteral:
        return hasSkipDirectInferenceFlag(node) ?
            blockedStringType :
            getFreshTypeOfLiteralType(getStringLiteralType((node as StringLiteralLike).text));
    case SyntaxKind.NumericLiteral:
        checkGrammarNumericLiteral(node as NumericLiteral);
        return getFreshTypeOfLiteralType(getNumberLiteralType(+(node as NumericLiteral).text));
    // ...
  }
}

例えば、elements の最初の要素である NumericLiteral { text: "1" } は text プロパティの値が数値に変換されて getNumberLiteralType 関数に渡り、 { flags: TypeFlags.NumberLiteral, value: 1, ... } のような NumberLiteralType に変換されます。

次に、このリテラル型が getWidenedLiteralLikeTypeForContextualType 関数に渡されます。
const v = [1, 'a', 2] という文脈では、as const のような強い制約がないため、TypeScriptは柔軟性を優先します。
その後 getWidenedLiteralType 関数 が呼び出され、NumberLiteralType はより広い numberType へと「拡大(widen)」されます。

// checker.ts
function getWidenedLiteralType(type: Type): Type {
  return type.flags & TypeFlags.EnumLike && isFreshLiteralType(type) ? getBaseTypeOfEnumLikeType(type as LiteralType) :
    type.flags & TypeFlags.StringLiteral && isFreshLiteralType(type) ? stringType : // これと
    type.flags & TypeFlags.NumberLiteral && isFreshLiteralType(type) ? numberType : // これが対象
    type.flags & TypeFlags.BigIntLiteral && isFreshLiteralType(type) ? bigintType :
    type.flags & TypeFlags.BooleanLiteral && isFreshLiteralType(type) ? booleanType :
    type.flags & TypeFlags.Union ? mapType(type as UnionType, getWidenedLiteralType) :
    type;
}

なお、 numberType の実体は { flags: TypeFlags.Number, intrinsicName: "number", ... } のようなオブジェクトであり、 1という数値情報は消失します。あくまで number であるという型を示すデータとなりました。

ここまでが、 elements に対する1回目のループ処理になります。

このループが終わると、 checkArrayLiteral の中の elementTypes 配列は以下のようなデータになります。
同様に、次の StringLiteral { text: "a" }, NumericLiteral { text: "2" } も処理され、 checkArrayLiteralelements ループ処理の結果として以下のような配列が得られました。
この時点で elementTypes には以下のような型が格納されています。

elementTypes = [
  numberType, // { flags: TypeFlags.Number, ... }
  stringType, // { flags: TypeFlags.String, ... }
  numberType  // { flags: TypeFlags.Number, ... }
]

型の統合と配列型の完成

いよいよ大詰めです!
checkArrayLiteral は最後に、集めた elementTypes を元に配列型を組み立てます。

// checker.ts
return createArrayLiteralType(createArrayType(
  getUnionType(elementTypes, ...),
  ...
));

(実際には sameMap などがありますが、ここでは単純化しています)

  1. getUnionType: この関数は [numberType, stringType, numberType] という型のリストを受け取り、重複を排除して 1{ flags: TypeFlags.Union, types: [numberType, stringType] }という共用体型(Union Type)、つまりstring | string` を生成します。(中では型IDによるソートがあり string が先にくるようです)
  2. createArrayType: 次に、この number | string 型を要素の型として、Array<number | string>、つまり (number | string)[] という配列型の基本骨格を作成します。
  3. createArrayLiteralType: 最後にこの関数が、出来上がった配列型に「これはリテラル [...] から作られましたよ」という印(メタデータ)を付けて、処理を完了します。

これで、const v = [1, 'a', 2] の型が (string | number)[] として推論される理由がわかりました!

旅の感想

かなり Copilot や Gemini に助けてもらいながら読み進めましたが、TypeScript の型推論の仕組みをほんの少し(1% くらい)理解できた気がします。checker.ts だけで約5万行もあるので、当然といえば当然ですね⋯。
正直、自分が今どこを読んでいるのか頻繁に迷子になり、だいぶ沼にハマりました。AI なしで読み解こうとすると、相当な根気と時間が必要になると思います。

普段わたしたちが利用している言語の裏側には途方もない努力と工夫、そして苦悩があったんだろうなと改めて実感しました。
型推論の仕組みは非常に複雑で、今回見た例は比較的シンプルな方でしたが、もっと複雑なケースになると、多数の関数が連携して動いていることがわかります。
これを作り上げた開発者たちは天才なのか?心の底からそう感じました。

とはいえ基本的な流れは AST を解析し、各ノードの型を決定しながら深ぼっていくというものでした。
この一連のフローがわかっていれば、TypeScript の型推論の挙動を理解する手助けになると思います。

そして何より、「あ、これはあの関数に渡って、こういう型に推論されてるんだな」と、普段の開発の中で気づけるようになると、一層おもしろく感じられますね!

スペースマーケット Engineer Blog

Discussion