Hoi hoi! 👋

我是 @nyaomaru,一位正在探索 Jev 新可能性的前端工程師 😸(我也對 OpenAI 的「Decisions API」很感興趣)

最近,我在研究 TypeScript Compiler API,並提出了一個相當具體的問題

可重用的型別守衛,除了能保留 AST 節點型別之外,還能保留被縮小範圍的子屬性嗎?

一開始,我以為這可能是 Compiler API 特有的問題。

後來我用普通的 TypeScript 物件重現了同樣的模式。

這改變了我看待這件事的方式。

有趣的問題其實不在 AST 本身。

而是在於 屬性精煉。

來看看吧!👀

Image description


🌲 一個非常常見的 Compiler API 模式

假設我們有一個寬泛的 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 結構在好幾個地方都會出現:

  • visitor
  • filter
  • find
  • 另一個轉換
  • 另一個 lint 規則

這時候,給這個檢查一個名稱就開始有意義了。

自 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 縮小過的父層上的某個屬性,並保留被檢查的子層型別。

所以概念就是

在執行期只檢查一次子層,然後把同樣的事實帶回父層型別中。


😸 這其實不是 AST 的問題

研究過程中,讓我驚訝的就是這一點。

我一開始以為我在研究 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 只是這個更通用的組合問題的一個進階範例。


🔗 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 是區塊
  • 第一個語句存在
  • 第一個語句是 return statement

我們可以把各個部分分開建立。

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。

只是把小型守衛組合在一起而已。


🔒 為什麼只能用一個具體的 key 或 index?

這裡有一個重要限制。

一次成功的查找,只能證明一個具體位置。

如果我們檢查

refineKey("expression", ...)

我們只證明了關於

parent.expression;

的事。

我們沒有證明某個更廣泛的 key 範圍裡每個屬性都通過了同樣的測試。

這就是為什麼這些精煉輔助函式刻意只處理單一具體 key 或 index。

太廣的 key 聯集與類似的多位置宣稱,會讓結果型別很容易被誇大。

我寧可讓 API 稍微沒那麼魔法,也不要讓一次執行期查找宣稱了比它實際檢查更多的東西。


🧪 那 TypeScript 7 呢?

這份研究之所以特別有趣,是因為 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 本身一起演進。


✋ 你大概不需要對每個 AST 檢查都這樣做

這點也很重要。

如果我只有一個局部條件

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 函式
  • 驗證完整 AST 節點結構
  • 控制 AST 走訪
  • 偵測 AST cycle
  • 新增 TypeScript runtime 相依
  • 要求 TypeScript 作為 peer dependency
  • 取代清楚的單次 inline 檢查

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 上給個 ⭐!

感謝閱讀!🙌


原文出處:https://dev.to/nyaomaru/typescript-compiler-api-preserving-child-node-narrowing-in-reusable-type-guards-4pgh


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝56   💬2  
736
🥈
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登