Hoi hoi! 👋
我是 @nyaomaru,一位正在探索 Jev 新可能性的前端工程師 😸(我也對 OpenAI 的「Decisions API」很感興趣)
最近,我在研究 TypeScript Compiler API,並提出了一個相當具體的問題
可重用的型別守衛,除了能保留 AST 節點型別之外,還能保留被縮小範圍的子屬性嗎?
一開始,我以為這可能是 Compiler API 特有的問題。
後來我用普通的 TypeScript 物件重現了同樣的模式。
這改變了我看待這件事的方式。
有趣的問題其實不在 AST 本身。
而是在於 屬性精煉。
來看看吧!👀

假設我們有一個寬泛的 ts.Node。
import * as ts from "typescript";
declare const node: ts.Node;
我們想知道兩件事:
CallExpression?expression 是不是一個 Identifier?如果直接寫在一起,很簡單。
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
// node: ts.CallExpression
// node.expression: ts.Identifier
node.expression.text;
}
TypeScript 能完美理解控制流程。
不需要任何特別處理。
老實說,如果這個檢查只會出現一次,我大概也會就這樣保留原樣。
現在假設同樣的 AST 結構在好幾個地方都會出現:
filterfind這時候,給這個檢查一個名稱就開始有意義了。
自 TypeScript v5.5 起,簡單函式通常可以自動推斷出型別判定詞。
但這種「父節點 + 子節點」的複合檢查不太一樣。
const isCallWithIdentifierExpression = (node: ts.Node) =>
ts.isCallExpression(node) && ts.isIdentifier(node.expression);
// inferred:
// (node: ts.Node) => boolean
所以如果我們想讓抽出的判定同時保留這兩個事實,就必須明確描述 精煉後的型別。
const isCallWithIdentifierExpression = (
node: ts.Node,
): node is ts.CallExpression & {
expression: ts.Identifier;
} => ts.isCallExpression(node) && ts.isIdentifier(node.expression);
這樣可行。接著我們就可以重用它
declare const nodes: readonly ts.Node[];
const calls = nodes.filter(isCallWithIdentifierExpression);
// calls:
// Array<
// ts.CallExpression & {
// expression: ts.Identifier;
// }
// >
那問題在哪?
其實沒有什麼執行期問題。
讓人覺得麻煩的是,我們必須手動描述這個 👇
ts.CallExpression & {
expression: ts.Identifier;
}
但我們其實已經做了完全相同的執行期檢查。
如果可以把這些檢查組合起來,讓型別跟著走,那就好了。😸
這就是我最後使用 refineKey 的原因。
搭配 is-kit
import * as ts from "typescript";
import { and, refineKey } from "is-kit";
const isCallWithIdentifierExpression = and(
ts.isCallExpression,
refineKey("expression", ts.isIdentifier),
);
就這樣。
現在
declare const node: ts.Node;
if (isCallWithIdentifierExpression(node)) {
// node:
// ts.CallExpression & {
// expression: ts.Identifier;
// }
node.expression.text;
}
有趣的部分在於這兩個檢查之間的關係。
ts.isCallExpression;
先縮小父層。
接著
refineKey("expression", ts.isIdentifier);
在組合起來的縮小鏈中,會檢查已經被 ts.isCallExpression 縮小過的父層上的某個屬性,並保留被檢查的子層型別。
所以概念就是
在執行期只檢查一次子層,然後把同樣的事實帶回父層型別中。
研究過程中,讓我驚訝的就是這一點。
我一開始以為我在研究 TypeScript Compiler API 的缺口。
但同樣的結構也會出現在一般物件上。
概念上,這個模式只是
Parent
↓
檢查屬性
↓
Parent & {
property: RefinedChild
}
Compiler API 只是個非常好的壓力測試,因為 AST 程式碼到處都充滿這種模式。
例如
CallExpression
→ expression
→ Identifier
或
VariableDeclaration
→ initializer?
→ CallExpression
或
CallExpression
→ arguments[0]
→ StringLiteral
所以我不會把 refineKey 看作是 Compiler API 的輔助工具。
Compiler API 只是這個更通用的組合問題的一個進階範例。
另一件我想避免的事,是不必要地包裝 TypeScript 現有的判定函式。
Compiler API 已經提供很棒的守衛了
ts.isStringLiteral;
ts.isIdentifier;
ts.isCallExpression;
ts.isClassDeclaration;
我們應該重用它們。
例如
import * as ts from "typescript";
import { or } from "is-kit";
const isStringLike = or(ts.isStringLiteral, ts.isNoSubstitutionTemplateLiteral);
declare const nodes: readonly ts.Node[];
const strings = nodes.filter(isStringLike);
// strings:
// (
// | ts.StringLiteral
// | ts.NoSubstitutionTemplateLiteral
// )[]
沒有理由讓 is-kit 自己再建立一套
isTsStringLiteral();
isTsIdentifier();
isTsCallExpression();
那樣只是把 Compiler API 又複製一遍而已。
真正有價值的部分,是組合。
find 和 visitor 中重用同一個守衛當某個精煉後的結構會在多種情境中出現時,這就更有用了。
例如
import * as ts from "typescript";
import { and, refineKey } from "is-kit";
const isIdentifierNamedJsxAttribute = and(
ts.isJsxAttribute,
refineKey("name", ts.isIdentifier),
);
我們可以把它用在 find:
declare const attributes: readonly ts.JsxAttributeLike[];
const attribute = attributes.find(isIdentifierNamedJsxAttribute);
// attribute:
// (
// ts.JsxAttribute & {
// name: ts.Identifier;
// }
// ) | undefined
同一個守衛也能用在 visitor 裡
function visit(node: ts.Node): void {
if (isIdentifierNamedJsxAttribute(node)) {
// node:
// ts.JsxAttribute & {
// name: ts.Identifier;
// }
node.name.text;
}
ts.forEachChild(node, visit);
}
這就是抽出守衛開始真正有價值的地方。
執行期規則與 TypeScript 的縮小範圍會一起傳遞。
AST 節點裡有很多可選屬性。
例如,VariableDeclaration 可能有 initializer,也可能沒有。
declaration.initializer;
所以這和精煉必要屬性稍微不同。
我們不只是想要
精煉
initializer。
我們想要的是
先要求
initializer存在,再精煉它。
對這種情況,is-kit 提供了 refineDefinedKey。
import * as ts from "typescript";
import { refineDefinedKey } from "is-kit";
const hasCallInitializer = refineDefinedKey("initializer", ts.isCallExpression);
現在
declare const declaration: ts.VariableDeclaration;
if (hasCallInitializer(declaration)) {
// declaration.initializer: ts.CallExpression
declaration.initializer.expression;
}
在這個區塊內,initializer 同時具備:
ts.CallExpression缺少 initializer 時會回傳 false。
顯式為 undefined 的 initializer 也會回傳 false。
我喜歡把這和 refineKey 分開,因為「不存在」是執行期行為,不只是 TypeScript 的標註而已。
AST 陣列還會帶來另一個小問題。
假設我們想找一個「第一個參數是字串字面值」的 call。
這個
node.arguments[0];
看起來很簡單,但在執行期陣列可能是空的。
所以我們想證明兩件事:
0 存在StringLiteral這也可以組合起來
import * as ts from "typescript";
import { and, refineIndex, refineKey } from "is-kit";
const isCallWithStringFirstArgument = and(
ts.isCallExpression,
refineKey("arguments", refineIndex(0, ts.isStringLiteral)),
);
然後
declare const node: ts.Node;
if (isCallWithStringFirstArgument(node)) {
// node: ts.CallExpression
// node.arguments[0]: ts.StringLiteral
node.arguments[0].text;
}
現在索引 0 已知存在,而且是 ts.StringLiteral。
同樣地,這其實不是 AST 專屬的想法。
它只是
精煉一個已檢查的位置,並保留這個事實。
這些精煉也可以巢狀使用。
假設我們想要一個類函式宣告,而且它的:
body 存在body 是區塊我們可以把各個部分分開建立。
import * as ts from "typescript";
import { and, refineDefinedKey, refineIndex, refineKey } from "is-kit";
const isBlockStartingWithReturn = and(
ts.isBlock,
refineKey("statements", refineIndex(0, ts.isReturnStatement)),
);
const hasBodyStartingWithReturn = refineDefinedKey(
"body",
isBlockStartingWithReturn,
);
然後
declare const functionLike: ts.FunctionLikeDeclaration;
if (hasBodyStartingWithReturn(functionLike)) {
// functionLike.body: ts.Block
// functionLike.body.statements[0]: ts.ReturnStatement
functionLike.body.statements[0].expression;
}
每一步都只證明一件事。
沒有像這樣的路徑字串
body.statements[0]
也沒有特殊的 AST DSL。
只是把小型守衛組合在一起而已。
這裡有一個重要限制。
一次成功的查找,只能證明一個具體位置。
如果我們檢查
refineKey("expression", ...)
我們只證明了關於
parent.expression;
的事。
我們沒有證明某個更廣泛的 key 範圍裡每個屬性都通過了同樣的測試。
這就是為什麼這些精煉輔助函式刻意只處理單一具體 key 或 index。
太廣的 key 聯集與類似的多位置宣稱,會讓結果型別很容易被誇大。
我寧可讓 API 稍微沒那麼魔法,也不要讓一次執行期查找宣稱了比它實際檢查更多的東西。
這份研究之所以特別有趣,是因為 TypeScript v7 改變了 Compiler API 的版圖。
本節範例是以 TypeScript v7.0.2 驗證的。
截至 TypeScript v7.0.2,AST 型別與判定函式是透過
typescript/unstable/ast
公開的。
所以同樣的組合方式也可以用在那裡
import * as ast from "typescript/unstable/ast";
import { and, refineKey } from "is-kit";
const isCallWithIdentifierExpression = and(
ast.isCallExpression,
refineKey("expression", ast.isIdentifier),
);
我特別研究的一件事,是 TypeScript v7 是否會透過 kind 縮小範圍,讓這些 isX 檢查變得不必要。
對真正的 discriminated union 來說,TypeScript 當然可以從字面 discriminant 進行縮小。
但 TypeScript v7 AST 介面中暴露的寬泛 AST Node,目前並不是那種封閉式 discriminated union。
所以對寬泛的 AST node 而言,isX 判定仍然很重要。
例如
import * as ast from "typescript/unstable/ast";
declare const node: ast.Node;
if (node.kind === ast.SyntaxKind.CallExpression) {
// broad ast.Node does not automatically
// expose CallExpression properties here
}
這個區別很重要。
以 discriminated union 建模的自訂 AST 型別,行為可能會不同。
這不代表 TypeScript v7 的寬泛 Node 現在就會有相同表現。
Node 做成封閉式聯集?在我發表這件事之後,Jake Bailey 給了一個非常精簡的回答:
because it's slow 😞
https://bsky.app/profile/jakebailey.dev/post/3mwpa3wwmjs2d
這讓這個取捨更容易理解了。
如果 Node 是一個包含所有 AST 節點型別的封閉式 discriminated union,kind 或許就能提供更強的縮小範圍與窮舉檢查。
例如,在封閉聯集中,我們可以使用熟悉的 never 模式
switch (node.kind) {
// handle every known kind...
default: {
const exhaustive: never = node;
}
}
當新增一個變體時,這個 never 檢查就可能在編譯期失敗,告訴我們處理已經不再是窮舉的了。
但這種更強的型別層級模型不是沒有成本的。
成本就是型別檢查效能:非常大的封閉聯集會讓檢查器做更多工作。
所以 ast.Node 的寬泛形狀,不只是缺少縮小能力而已。
這裡確實存在一個真實的取捨:
更強的編譯期窮舉性 vs. 型別檢查效能
這也有助於解釋,為什麼像 ast.isCallExpression() 這種顯式判定依然很重要。
還有一個值得記住的 TypeScript 7 細節。
typescript/unstable/ast 中的 unstable 也很重要。
我不會基於一個仍在演進中的 API 介面去建立文件承諾。
這個組合模式是通用的。
TypeScript v7 的具體整合方式,可以隨著 TypeScript 本身一起演進。
這點也很重要。
如果我只有一個局部條件
if (ts.isReturnStatement(node) && node.expression) {
// node: ts.ReturnStatement
// node.expression: ts.Expression
visit(node.expression);
}
我會把它直接留在原地。
真的。
只是因為「我們可以」,就把它改成
const isReturnWithExpression = ...
並不會自動讓程式碼更好。
我認為有用的區分是
| 情境 | 建議做法 |
|---|---|
| 單一局部分支 | 直接使用原生 ts.isX 檢查 |
| 重複出現的 AST 結構 | 命名並重用守衛 |
專案已使用 is-kit |
refineKey、refineDefinedKey、refineIndex |
目標不是
把每個
ts.isX條件都改成is-kit。
目標是
當一個執行期事實變成可重用的語彙時,也讓縮小範圍保持可重用。
is-kit 並不打算變成 Compiler API 框架。
它不會:
Compiler API 只是個要求很高的真實世界範例,用來說明通用的屬性精煉。
我想保留的界線就是這個。
我一開始研究這件事時,心裡想的是
也許 TypeScript Compiler API 需要某種特殊處理。
但我找到的是更通用的東西。
反覆出現的問題其實是
縮小父層
↓
檢查子層
↓
保留兩個事實
↓
重用這個判定
這對 AST 節點有用,但它其實不只是 AST 節點的問題。
所以我現在的心智模型是:
對 Compiler API 而言,這可以像這樣
const isCallWithIdentifierExpression = and(
ts.isCallExpression,
refineKey("expression", ts.isIdentifier),
);
小型執行期檢查。
小型可重用片段。
而 TypeScript 會保留我們實際檢查過的事實。😸
我也另外寫了一份更詳細的指南,涵蓋必要子層、可選子層、陣列索引、巢狀 AST 結構,以及 TypeScript 7
使用 TypeScript Compiler API 進行進階屬性精煉
https://is-kit.dev/guides/typescript-compiler-api
如果你想進一步探索 is-kit 本身
https://github.com/nyaomaru/is-kit
如果你覺得有幫助,也非常歡迎到 GitHub 上給個 ⭐!
感謝閱讀!🙌