こんにちは。ファインディでフロントエンドエンジニアをしている大石(@bicstone)です。
2026年5月22日〜23日に開催されるTSKaigi 2026で、「プロパティの順序で型推論が壊れる!? TS6.0の修正からContext-Sensitivityの仕組みを追う」というタイトルで登壇します。本記事は10分のトーク内では時間の都合で端折った内容も含めた拡張版としてお届けします。
TypeScriptでオブジェクトリテラルにメソッドを書いたとき、プロパティの記述順序を入れ替えただけで型推論の結果が変わるとしたら、どう感じるでしょうか。「そんな不思議な挙動があるのか」「気になるけどTypeScript内部のソースコードは難しそうで自分には縁がない」と思った方もいるかもしれません。
この記事では、この不思議な挙動を入り口にしてTypeScriptコンパイラの中で何が起きているのかを1つのPRから追いながら解説します。
- 背景: プロパティの順序で型が変わる
- アロー関数なら問題ないのはなぜか
- Context-Sensitive Functionの考え方
- 推論の全体像
- メソッド構文に隠れている暗黙のthisパラメータ
- 両方スキップで起こる順序依存の正体
- 今回の修正を読み解く
- 修正前後の推論を比較する
- TypeScriptのコンパイラを読む最初の一歩
- おわりに
- 参考資料
背景: プロパティの順序で型が変わる
次のtest関数を見てください。型パラメータTを持ち、2つのプロパティaとbを受け取ります。
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のジェネリック関数における型推論は、おおまかに次の流れで行われます。
- 引数を走査し、context-sensitiveな関数を特定する
- context-sensitiveな関数をスキップする
- 残りから型パラメータ
Tを推論する - 確定した
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が先に書かれている場合、推論パイプラインでは次のように処理されます。
a(c)→ 暗黙のthis→ context-sensitive → スキップb()→ 暗黙のthis→ context-sensitive → スキップ- 両方スキップ →
Tを推論するソースがない - フォールバックとして、左 → 右の順番で処理を試みる
a(c)を先に処理 →Tが未確定 →c: unknown
bが先に書かれていた場合はどうなるでしょうか。
- 同じく両方スキップされる
- フォールバックとして、左 → 右の順番で
b()を先に処理 b()の戻り値123からT = numberを推論a(c)にT = numberを適用 →c: number
これがプロパティの順序で型が変わる原因です。フォールバック処理が左 → 右の順番で行われるため、どちらのプロパティが先に書かれているかが推論結果を左右していました。
今回の修正を読み解く
この問題を修正するTypeScriptのPR #62243はAndarist氏によって実装され、2025年12月にマージされました。
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.tsのhasContextSensitiveParameters関数は、関数が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では、isContextSensitiveのswitchにYieldExpressionのケースを追加し、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
a(c)→ 暗黙のthis→ context-sensitive → スキップb()→ 暗黙のthis→ context-sensitive → スキップ- 両方スキップ →
Tを推論するソースなし - フォールバックとして、左→右の順番で処理
a(c)を先に処理 →Tが未確定c: unknown
TypeScript 6.0
a(c)→this不使用 →ContainsThisなし → スキップしないb()→this不使用 →ContainsThisなし → スキップしない- 通常の推論へ
b()の戻り値からT = numberを推論a(c)にT = numberを適用c: number
thisを使っていないメソッド構文の関数がcontext-sensitiveと判定されなくなったことで、アロー関数と同じ推論に入るようになりました。プロパティの記述順序に依存しなくなります。
TypeScriptのコンパイラを読む最初の一歩
TypeScriptコンパイラのchecker.tsは5万行を超える巨大なファイルです。kkk4oruさんがTSKaigi 2025で登壇された「checker.tsに対して真剣に向き合う」も印象に残っている方が多いのではないでしょうか。
5万行に圧倒されますが、一つの機能を追うのであれば全部読む必要はありません。
OSSのPRを読むときは、次の観点で読み進めると迷子になりにくくなります。
- リンクされているIssueを先に読み、課題や何を解決しようとしているのかを理解
- 先に修正されたテストを読むことで、何を直したのかを理解
- PRの説明やレビューコメントを読むことで、修正の意図を理解
NodeFlags/TypeFlagsが増えていれば、それを追うことで変更の意図を理解
これらは今回の自分の読解で実際に役に立った観点です。
おわりに
1つのPRからTypeScriptの型推論の仕組みを追うことで、Context-Sensitivityという概念と、TypeScriptの内部に踏み込むきっかけを持ち帰っていただけたら嬉しいです。
参考資料
- Announcing TypeScript 6.0 Beta - TypeScript
- microsoft/TypeScript - GitHub
- Improve inference by not considering thisless functions to be context-sensitive by Andarist · Pull Request #62243 · microsoft/TypeScript
- microsoft/TypeScript-Compiler-Notes - GitHub
- TypeScript Compiler Internals | TypeScript Deep Dive
ファインディでは一緒に会社を盛り上げてくれるメンバーを募集中です。興味を持っていただいた方はこちらのページからご応募お願いします。
本記事のbinder.ts / utilities.ts / checker.tsのコードは、microsoft/TypeScriptから引用しており、Apache License 2.0のもとで配布されています。