プロパティの順序で型推論が壊れる!? TypeScript6.0の修正からContext-Sensitivityの仕組みを追う

こんにちは。ファインディでフロントエンドエンジニアをしている大石(@bicstone)です。

2026年5月22日〜23日に開催されるTSKaigi 2026で、「プロパティの順序で型推論が壊れる!? TS6.0の修正からContext-Sensitivityの仕組みを追う」というタイトルで登壇します。本記事は10分のトーク内では時間の都合で端折った内容も含めた拡張版としてお届けします。

2026.tskaigi.org

TypeScriptでオブジェクトリテラルにメソッドを書いたとき、プロパティの記述順序を入れ替えただけで型推論の結果が変わるとしたら、どう感じるでしょうか。「そんな不思議な挙動があるのか」「気になるけどTypeScript内部のソースコードは難しそうで自分には縁がない」と思った方もいるかもしれません。

この記事では、この不思議な挙動を入り口にしてTypeScriptコンパイラの中で何が起きているのかを1つのPRから追いながら解説します。

背景: プロパティの順序で型が変わる

次のtest関数を見てください。型パラメータTを持ち、2つのプロパティabを受け取ります。

function test<T>(options: {
  a: (c: T) => void;
  b: () => T;
}) {}

次の2つの呼び出しは、プロパティの記述順序が違うだけです。しかし、TypeScript 5.9以前では、この2つの結果は異なります。

test({ b() { return 123 }, a(c) { return c } });  // c: number
test({ a(c) { return c }, b() { return 123 } });  // c: unknown

プロパティの順序を入れ替えただけでcの型がnumberからunknownに変わります。

手元で挙動を確認したい場合は、TypeScript Playgroundを開いてみてください。cをホバーするとunknownが表示されるはずです。

アロー関数なら問題ないのはなぜか

一方、同じコードをアロー関数で書き換えると、順序に関係なく推論が通ります。

// アロー関数は、どちらの順序でもc: number
test({ a: (c) => { c }, b: () => 123 });  // c: number
test({ b: () => 123, a: (c) => { c } });  // c: number

メソッド構文では順序に依存するのに、アロー関数では依存しない。この差はどこから来るのでしょうか。

答えはTypeScriptの型推論パイプラインにおけるContext-Sensitive Functionという考え方にあります。

Context-Sensitive Functionの考え方

Context-Sensitive Functionとは、型注釈のないパラメータを持つ関数のことです。パラメータの型を決めるために、外部のコンテキストが必要になります。

// context-sensitive:
(c) => c          // cの型がわからない → 外部に依存

// context-sensitiveでない:
(c: number) => c  // cの型が明示されている
() => 123         // パラメータがない

TypeScriptの型推論において、context-sensitiveな関数は特別扱いされます。推論の過程で「後回し」にされるのです。

なぜ後回しにする必要があるのでしょうか。次のコードで考えてみます。

function callFunc<T>(callback: (x: T) => void, value: T) {}

callFunc(x => x.toFixed(), 123);
//       ^                 ^^^
// xの型を知りたい        Tの情報源

callbackの引数xの型を決めるにはTが必要です。しかしTを推論するためにはcallbackの情報も必要です。鶏と卵の問題が発生します。

TypeScriptはこれを解決するために、context-sensitiveな関数(ここではcallback)をいったんスキップし、他の引数(ここでは123)から先にT = numberを推論します。その後、callbackに戻ってx: numberを確定させます。

推論の全体像

TypeScriptのジェネリック関数における型推論は、おおまかに次の流れで行われます。

  1. 引数を走査し、context-sensitiveな関数を特定する
  2. context-sensitiveな関数をスキップする
  3. 残りから型パラメータTを推論する
  4. 確定したTで後回しの関数のパラメータを型付けする

ステップ2で全ての関数がスキップされてしまうとTを推論するソースがなくなります。その場合、TypeScriptはフォールバック処理に入り、左から右の順番で処理を試みます。

メソッド構文に隠れている暗黙のthisパラメータ

メソッド構文で書かれた関数は、暗黙のthisパラメータを持ちます。

// メソッド構文
{ a(c) { return c } }
// ↓ TypeScriptの内部的な解釈
{ a(this: ???, c: ???) { return c } }
//  ^^^^ 暗黙のthis → 型注釈なし → context-sensitive

thisパラメータには型注釈がないため、TypeScriptはこの関数をcontext-sensitiveと判定します。thisを実際には使っていなくても、存在するだけでcontext-sensitiveになってしまいます。

一方、アロー関数はthisパラメータを持ちません。

// アロー関数
{ a: (c) => c }
// → thisパラメータなし
// → cだけが未注釈のパラメータ

この差が、メソッド構文とアロー関数で推論の挙動が異なる根本的な原因です。

両方スキップで起こる順序依存の正体

冒頭の例に戻りましょう。

test({ a(c) { return c }, b() { return 123 } });

aが先に書かれている場合、推論パイプラインでは次のように処理されます。

  1. a(c) → 暗黙のthis → context-sensitive → スキップ
  2. b() → 暗黙のthis → context-sensitive → スキップ
  3. 両方スキップ → Tを推論するソースがない
  4. フォールバックとして、左 → 右の順番で処理を試みる
  5. a(c)を先に処理 → Tが未確定 → c: unknown

bが先に書かれていた場合はどうなるでしょうか。

  1. 同じく両方スキップされる
  2. フォールバックとして、左 → 右の順番でb()を先に処理
  3. b()の戻り値123からT = numberを推論
  4. a(c)T = numberを適用 → c: number

これがプロパティの順序で型が変わる原因です。フォールバック処理が左 → 右の順番で行われるため、どちらのプロパティが先に書かれているかが推論結果を左右していました。

今回の修正を読み解く

この問題を修正するTypeScriptのPR #62243はAndarist氏によって実装され、2025年12月にマージされました。

github.com

thisを実際に使っていない関数は、context-sensitiveと見なさない、という修正が実施されました。

この変更は3つのファイルにまたがっています。

ファイル 役割 変更内容
binder.ts フラグを立てる 関数内のthis使用を追跡し、ContainsThisフラグを設定する
utilities.ts 判定を変える ContainsThisがなければ、暗黙のthisパラメータを無視する
checker.ts 補完する Generator固有のエッジケースに対応する

それぞれの変更箇所を見ていきます。

なお、以降に掲載するコードは、解説の都合上、各ファイルから該当箇所のみを抜粋し、// ... 中略 ...などで省略しています。実装の正確な差分はPR #62243のFiles changedをあわせて参照してください。

binder.ts: ContainsThisフラグを立てる

TypeScriptのbinder.tsは、AST(抽象構文木)を走査してシンボルテーブルを構築します。今回は、関数ノードのバインド時にthisキーワードの使用を追跡する処理が追加されました。

まず、AST走査中にthisキーワードを見つけたら、seenThisKeywordフラグを立てます。

case SyntaxKind.ThisKeyword:
    if (node.kind === SyntaxKind.ThisKeyword) {
        seenThisKeyword = true;  // 追加
    }
    // ... 既存処理 ...

次に、関数のコンテナをバインドするbindContainerで、関数本体のバインド前後でseenThisKeywordを退避・初期化し、本体内でthisが出現していればNodeFlags.ContainsThisを立てる処理が追加されました。

const saveSeenThisKeyword = seenThisKeyword;  // 退避(追加)
// ... 他ステートの退避 ...

seenThisKeyword = false;                      // 初期化(追加)
bindChildren(node);
// 増分コンパイル向けのリセット対象にContainsThisを追加
node.flags &= ~(NodeFlags.ReachabilityAndEmitFlags | NodeFlags.ContainsThis);

// ... 中略 ...

if (seenThisKeyword) {                        // フラグを立てる(追加)
    node.flags |= NodeFlags.ContainsThis;
}

// ... 中略 ...

// 復元(アロー関数は内部のthis使用を外側に伝播)
seenThisKeyword = node.kind === SyntaxKind.ArrowFunction
    ? saveSeenThisKeyword || seenThisKeyword
    : saveSeenThisKeyword;

seenThisKeywordはバインド中にローカルに管理されるフラグで、関数本体内でthisキーワードが出現したかどうかを追跡します。関数のバインドが完了した時点でthisが使われていれば、NodeFlags.ContainsThisフラグがノードに設定されます。

末尾の復元処理がアロー関数とそれ以外で分岐しているのは、アロー関数自身はthisバインディングを持たず、内部のthisはコード上で自分を囲っている外側の関数のthisをそのまま参照するためです。アロー関数の内部でthisが使われていれば、外側のメソッドのContainsThisを立てる必要があります。

つまり、thisを使っている関数だけにContainsThisフラグが立ち、使っていない関数にはフラグが立ちません。

utilities.ts: hasContextSensitiveParametersの変更

utilities.tshasContextSensitiveParameters関数は、関数がcontext-sensitiveかどうかを判定するための関数です。

export function hasContextSensitiveParameters(node: FunctionLikeDeclaration): boolean {
    // Functions with type parameters are not context sensitive.
    if (!node.typeParameters) {
        // Functions with any parameters that lack type annotations are context sensitive.
        if (some(node.parameters, p => !getEffectiveTypeAnnotationNode(p))) {
            return true;
        }
        if (node.kind !== SyntaxKind.ArrowFunction) {
            // If the first parameter is not an explicit 'this' parameter, then the function has
            // an implicit 'this' parameter which is subject to contextual typing.
            const parameter = firstOrUndefined(node.parameters);
            if (!(parameter && parameterIsThisKeyword(parameter))) {
                return !!(node.flags & NodeFlags.ContainsThis);  // 変更箇所
            }
        }
    }
    return false;
}

実際の変更点は最後のreturnの1行のみです。

-                return true;
+                return !!(node.flags & NodeFlags.ContainsThis);

この行が発火するのは「型パラメータがない」「アロー関数ではない」「最初のパラメータが明示的なthisパラメータではない」という3条件を満たす場合、つまり暗黙のthisを持つメソッド構文の関数です。

変更前は、この条件に合致するメソッドを無条件にcontext-sensitiveとして扱っていました。変更後は、ContainsThisフラグが立っている(=本体でthisを実際に使っている)ときだけcontext-sensitiveとして扱うようになりました。

checker.ts: Generator固有のエッジケース

Generator関数はアロー関数として書くことができません(function*構文のみ)。そのため、thisを使わないGeneratorでも常にメソッド構文(=thisあり)で書かれることになります。

thisなしの関数がcontext-sensitiveでなくなると、Generator関数も一律にcontext-sensitiveでなくなってしまいます。しかし、Generator内のyield式自体がcontext-sensitiveである場合があります。

declare function pipe<T>(
  gen: () => Generator<(arg: T) => string, void, void>,
  initial: T,
): void;

pipe(function* () {
  yield (arg) => arg.toFixed(2);  // yield式がcontext-sensitive
}, 42);

このyield (arg) => arg.toFixed(2)は、Tの型が確定しないとargの型が決まりません。つまり、このGenerator全体はcontext-sensitiveとして扱う必要があります。

checker.tsでは、isContextSensitiveswitchYieldExpressionのケースを追加し、isContextSensitiveFunctionLikeDeclarationに新規関数hasContextSensitiveYieldExpressionを呼ぶ分岐が追加されました。

case SyntaxKind.JsxExpression:
case SyntaxKind.YieldExpression: {                             // 追加
    const { expression } = node as JsxExpression | YieldExpression;
    return !!expression && isContextSensitive(expression);
}
function isContextSensitiveFunctionLikeDeclaration(node: FunctionLikeDeclaration): boolean {
    return hasContextSensitiveParameters(node)
        || hasContextSensitiveReturnExpression(node)
        || hasContextSensitiveYieldExpression(node);             // 追加
}

function hasContextSensitiveYieldExpression(node: FunctionLikeDeclaration): boolean {  // 追加
    return !!(
        getFunctionFlags(node) & FunctionFlags.Generator
        && node.body
        && forEachYieldExpression(node.body as Block, isContextSensitive)
    );
}

これにより、yield式がcontext-sensitiveな場合はGenerator全体をcontext-sensitiveとして扱えるようになります。

修正前後の推論を比較する

冒頭のコードで、修正前後の推論を比較してみましょう。

test({ a(c) { return c }, b() { return 123 } });

TypeScript 5.9

  1. a(c) → 暗黙のthis → context-sensitive → スキップ
  2. b() → 暗黙のthis → context-sensitive → スキップ
  3. 両方スキップ → Tを推論するソースなし
  4. フォールバックとして、左→右の順番で処理
  5. a(c)を先に処理 → Tが未確定
  6. c: unknown

TypeScript 6.0

  1. a(c)this不使用 → ContainsThisなし → スキップしない
  2. b()this不使用 → ContainsThisなし → スキップしない
  3. 通常の推論へ
  4. b()の戻り値からT = numberを推論
  5. a(c)T = numberを適用
  6. c: number

thisを使っていないメソッド構文の関数がcontext-sensitiveと判定されなくなったことで、アロー関数と同じ推論に入るようになりました。プロパティの記述順序に依存しなくなります。

TypeScriptのコンパイラを読む最初の一歩

TypeScriptコンパイラのchecker.tsは5万行を超える巨大なファイルです。kkk4oruさんがTSKaigi 2025で登壇された「checker.tsに対して真剣に向き合う」も印象に残っている方が多いのではないでしょうか。

2025.tskaigi.org

5万行に圧倒されますが、一つの機能を追うのであれば全部読む必要はありません。

OSSのPRを読むときは、次の観点で読み進めると迷子になりにくくなります。

  • リンクされているIssueを先に読み、課題や何を解決しようとしているのかを理解
  • 先に修正されたテストを読むことで、何を直したのかを理解
  • PRの説明やレビューコメントを読むことで、修正の意図を理解
  • NodeFlags/TypeFlagsが増えていれば、それを追うことで変更の意図を理解

これらは今回の自分の読解で実際に役に立った観点です。

おわりに

1つのPRからTypeScriptの型推論の仕組みを追うことで、Context-Sensitivityという概念と、TypeScriptの内部に踏み込むきっかけを持ち帰っていただけたら嬉しいです。

参考資料


ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味を持っていただいた方はこちらのページからご応募お願いします。

herp.careers


本記事のbinder.ts / utilities.ts / checker.tsのコードは、microsoft/TypeScriptから引用しており、Apache License 2.0のもとで配布されています。