TypeScript Compiler API: Giữ nguyên narrowing của node con trong các type guard có thể tái sử dụng 🔧
Hoi hoi! 👋
Mình là @nyaomaru, một frontend engineer đang khám phá những khả năng mới với Jev 😸 (và mình cũng khá tò mò về “Decisions API” của OpenAI).
Gần đây, khi tìm hiểu TypeScript Compiler API, mình bắt đầu với một câu hỏi khá cụ thể:
Liệu một type guard có thể tái sử dụng có thể giữ lại không chỉ kiểu của AST node, mà cả kiểu đã được narrow của một property con hay không?
Ban đầu, mình nghĩ đây có thể là một vấn đề riêng của Compiler API.
Sau đó, mình thử tái hiện đúng pattern đó với các object TypeScript thông thường.
Và điều đó đã làm mình thay đổi cách nhìn về vấn đề này.
Điểm thú vị thật ra không nằm ở AST.
Mà là ở property refinement.
Cùng xem nhé! 👀

🌲 Một pattern rất phổ biến trong Compiler API
Giả sử chúng ta có một ts.Node khá rộng.
import * as ts from "typescript";
declare const node: ts.Node;
Chúng ta muốn biết hai điều:
- Đây có phải là
CallExpressionkhông? expressioncủa nó có phải làIdentifierkhông?
Nếu viết inline thì rất đơn giản.
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
// node: ts.CallExpression
// node.expression: ts.Identifier
node.expression.text;
}
TypeScript hiểu control flow ở đây rất tốt.
Không cần thêm bất kỳ xử lý đặc biệt nào.
Và thật ra, nếu check này chỉ xuất hiện một lần, mình có lẽ sẽ giữ nguyên như vậy.
🤔 Nếu muốn tái sử dụng shape này thì sao?
Giả sử cùng một AST shape xuất hiện ở nhiều nơi:
- visitor
filterfind- một transformation khác
- một lint rule khác
Lúc đó, việc đặt tên cho check này bắt đầu có ý nghĩa.
Từ TypeScript v5.5, các function đơn giản thường có thể tự suy luận type predicate.
Nhưng kiểu check kết hợp giữa parent + child như thế này thì khác.
const isCallWithIdentifierExpression = (node: ts.Node) =>
ts.isCallExpression(node) && ts.isIdentifier(node.expression);
// inferred:
// (node: ts.Node) => boolean
Nếu muốn predicate sau khi tách ra vẫn giữ được cả hai thông tin, chúng ta phải mô tả refined type một cách rõ ràng.
const isCallWithIdentifierExpression = (
node: ts.Node,
): node is ts.CallExpression & {
expression: ts.Identifier;
} => ts.isCallExpression(node) && ts.isIdentifier(node.expression);
Cách này hoạt động.
Sau đó có thể tái sử dụng:
declare const nodes: readonly ts.Node[];
const calls = nodes.filter(isCallWithIdentifierExpression);
// calls:
// Array<
// ts.CallExpression & {
// expression: ts.Identifier;
// }
// >
Vậy vấn đề là gì?
Thật ra không có vấn đề gì ở runtime.
Điểm khó chịu là chúng ta phải tự viết lại phần này:
ts.CallExpression & {
expression: ts.Identifier;
}
Trong khi runtime check phía trên đã kiểm tra đúng những điều đó rồi.
Sẽ tốt hơn nếu có thể compose các check và để type tự đi theo. 😸
🧩 Refine parent và child cùng nhau
Cuối cùng mình dùng refineKey.
Với is-kit:
import * as ts from "typescript";
import { and, refineKey } from "is-kit";
const isCallWithIdentifierExpression = and(
ts.isCallExpression,
refineKey("expression", ts.isIdentifier),
);
Chỉ vậy thôi.
Bây giờ:
declare const node: ts.Node;
if (isCallWithIdentifierExpression(node)) {
// node:
// ts.CallExpression & {
// expression: ts.Identifier;
// }
node.expression.text;
}
Điểm thú vị nằm ở mối quan hệ giữa hai check này.
ts.isCallExpression;
narrow parent.
Sau đó:
refineKey("expression", ts.isIdentifier);
trong chuỗi narrowing đã compose, nó kiểm tra một property trên parent đã được narrow bởi ts.isCallExpression, rồi giữ lại kiểu đã được xác nhận của child.
Ý tưởng là:
Chỉ kiểm tra child một lần ở runtime, rồi mang chính thông tin đó trở lại type của parent.
😸 Hóa ra đây không phải là vấn đề của AST
Đây là phần khiến mình bất ngờ nhất trong quá trình tìm hiểu.
Ban đầu mình nghĩ mình đang nghiên cứu một hạn chế của TypeScript Compiler API.
Nhưng pattern giống hệt cũng xuất hiện với object bình thường.
Về mặt khái niệm, nó chỉ là:
Parent
↓
check property
↓
Parent & {
property: RefinedChild
}
Compiler API đơn giản là một stress test rất tốt vì code xử lý AST có pattern này ở khắp nơi.
Ví dụ:
CallExpression
→ expression
→ Identifier
hoặc:
VariableDeclaration
→ initializer?
→ CallExpression
hoặc:
CallExpression
→ arguments[0]
→ StringLiteral
Vì vậy, mình không xem refineKey là helper riêng cho Compiler API.
Compiler API chỉ là một ví dụ nâng cao của một bài toán composition tổng quát hơn.
🔗 Các guard của Compiler API vốn đã rất dễ compose
Một điều khác mình muốn tránh là wrap lại các predicate mà TypeScript đã cung cấp sẵn.
Compiler API đã có các guard rất tốt:
ts.isStringLiteral;
ts.isIdentifier;
ts.isCallExpression;
ts.isClassDeclaration;
Chúng ta nên tái sử dụng chúng.
Ví dụ:
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
// )[]
Không có lý do gì để is-kit tạo thêm:
isTsStringLiteral();
isTsIdentifier();
isTsCallExpression();
Làm vậy chỉ lặp lại những gì Compiler API đã có.
Phần thực sự hữu ích là composition.
♻️ Tái sử dụng cùng một guard trong find và visitor
Điều này trở nên hữu ích hơn khi cùng một refined shape xuất hiện ở nhiều context.
Ví dụ:
import * as ts from "typescript";
import { and, refineKey } from "is-kit";
const isIdentifierNamedJsxAttribute = and(
ts.isJsxAttribute,
refineKey("name", ts.isIdentifier),
);
Có thể dùng với find:
declare const attributes: readonly ts.JsxAttributeLike[];
const attribute = attributes.find(isIdentifierNamedJsxAttribute);
// attribute:
// (
// ts.JsxAttribute & {
// name: ts.Identifier;
// }
// ) | undefined
Cùng guard đó cũng dùng được trong visitor:
function visit(node: ts.Node): void {
if (isIdentifierNamedJsxAttribute(node)) {
// node:
// ts.JsxAttribute & {
// name: ts.Identifier;
// }
node.name.text;
}
ts.forEachChild(node, visit);
}
Đây là lúc việc tách guard ra thực sự bắt đầu đáng giá.
Runtime rule và TypeScript narrowing có thể đi cùng nhau.
🫥 Optional child là một contract khác
AST node có rất nhiều optional property.
Ví dụ, một VariableDeclaration có thể có hoặc không có initializer.
declaration.initializer;
Điều này hơi khác so với việc refine một required property.
Chúng ta không chỉ muốn:
Refine
initializer.
Mà muốn:
Yêu cầu
initializertồn tại trước, sau đó refine nó.
Cho trường hợp này, is-kit có refineDefinedKey.
import * as ts from "typescript";
import { refineDefinedKey } from "is-kit";
const hasCallInitializer = refineDefinedKey(
"initializer",
ts.isCallExpression,
);
Bây giờ:
declare const declaration: ts.VariableDeclaration;
if (hasCallInitializer(declaration)) {
// declaration.initializer: ts.CallExpression
declaration.initializer.expression;
}
Bên trong branch này, initializer đồng thời:
- tồn tại
- là
ts.CallExpression
Initializer bị thiếu sẽ trả về false.
Initializer được đặt rõ ràng thành undefined cũng sẽ trả về false.
Mình thích tách trường hợp này khỏi refineKey, vì sự vắng mặt là một hành vi runtime, không chỉ là annotation của TypeScript.
📦 Array cũng có cùng vấn đề
Array trong AST tạo ra một vấn đề nhỏ khác.
Giả sử chúng ta muốn một call mà argument đầu tiên là string literal.
Dòng này:
node.arguments[0];
trông đơn giản, nhưng ở runtime array có thể rỗng.
Vậy nên thực ra chúng ta cần chứng minh hai điều:
- index
0tồn tại - value tại đó là
StringLiteral
Ta cũng có thể compose điều này:
import * as ts from "typescript";
import { and, refineIndex, refineKey } from "is-kit";
const isCallWithStringFirstArgument = and(
ts.isCallExpression,
refineKey("arguments", refineIndex(0, ts.isStringLiteral)),
);
Sau đó:
declare const node: ts.Node;
if (isCallWithStringFirstArgument(node)) {
// node: ts.CallExpression
// node.arguments[0]: ts.StringLiteral
node.arguments[0].text;
}
Bây giờ index 0 được biết là có tồn tại và là ts.StringLiteral.
Một lần nữa, đây không thực sự là ý tưởng riêng của AST.
Nó chỉ là:
Refine một vị trí đã được kiểm tra và giữ lại sự thật đó.
🪆 Nested check vẫn có thể compose
Các refinement này cũng có thể lồng vào nhau.
Giả sử chúng ta muốn một function-like declaration mà:
bodytồn tạibodylà block- statement đầu tiên tồn tại
- statement đầu tiên là return statement
Ta có thể xây từng phần riêng:
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,
);
Sau đó:
declare const functionLike: ts.FunctionLikeDeclaration;
if (hasBodyStartingWithReturn(functionLike)) {
// functionLike.body: ts.Block
// functionLike.body.statements[0]: ts.ReturnStatement
functionLike.body.statements[0].expression;
}
Mỗi bước chỉ chứng minh một điều.
Không có path string kiểu:
body.statements[0]
và cũng không có DSL đặc biệt cho AST.
Chỉ là những guard nhỏ được compose với nhau.
🔒 Vì sao chỉ hỗ trợ một key hoặc index cụ thể?
Có một giới hạn quan trọng ở đây.
Một lookup thành công chỉ chứng minh được một vị trí cụ thể.
Nếu ta check:
refineKey("expression", ...)
thì điều ta chứng minh là:
parent.expression;
Ta không chứng minh rằng mọi property trong một key domain rộng hơn đều vượt qua cùng một check.
Vì vậy, các refinement helper cố ý chỉ làm việc với một key hoặc index cụ thể.
Broad key union và các claim kiểu multi-location sẽ khiến type rất dễ nói quá những gì runtime thực sự đã kiểm tra.
Mình thà để API ít “magic” hơn một chút còn hơn để một runtime lookup khẳng định nhiều hơn những gì nó thực sự chứng minh.
🧪 Còn TypeScript 7 thì sao?
Phần nghiên cứu này trở nên thú vị hơn vì TypeScript v7 thay đổi khá nhiều bối cảnh của Compiler API.
Các ví dụ trong phần này được kiểm tra với TypeScript v7.0.2.
Ở TypeScript v7.0.2, các AST type và predicate được expose qua:
typescript/unstable/ast
Vì vậy cùng kiểu composition vẫn có thể dùng ở đó:
import * as ast from "typescript/unstable/ast";
import { and, refineKey } from "is-kit";
const isCallWithIdentifierExpression = and(
ast.isCallExpression,
refineKey("expression", ast.isIdentifier),
);
Một điều mình đặc biệt tìm hiểu là liệu TypeScript v7 có khiến các check isX không còn cần thiết nhờ kind narrowing hay không.
Với một discriminated union thực sự, TypeScript hoàn toàn có thể narrow từ literal discriminant.
Nhưng broad Node hiện được expose bởi AST surface của TypeScript v7 không phải loại closed discriminated union đó.
Vì vậy, với một broad AST node, các predicate isX vẫn còn rất quan trọng.
Ví dụ:
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
}
Sự khác biệt này rất quan trọng.
Một custom AST type được model thành discriminated union có thể hoạt động khác.
Điều đó không có nghĩa broad Node hiện tại của TypeScript v7 cũng hoạt động như vậy.
Tại sao không biến Node thành một Closed Union?
Sau khi mình đăng về chuyện này, Jake Bailey đã trả lời cực kỳ ngắn gọn:
because it's slow 😞
Câu trả lời đó làm trade-off trở nên rất dễ hiểu.
Nếu Node là một closed discriminated union chứa mọi loại AST node, thì về lý thuyết kind có thể mang lại narrowing mạnh hơn và exhaustive check tốt hơn.
Ví dụ, với closed union, ta có thể dùng pattern quen thuộc với never:
switch (node.kind) {
// handle every known kind...
default: {
const exhaustive: never = node;
}
}
Khi một variant mới được thêm vào, check never có thể fail ở compile time và cho biết handling không còn exhaustive nữa.
Nhưng mô hình type mạnh hơn này không miễn phí.
Cái giá phải trả là type-checking performance.
Một closed union rất lớn sẽ khiến checker phải làm nhiều việc hơn.
Vì vậy, shape rộng của ast.Node không đơn giản chỉ là một narrowing feature còn thiếu.
Có một trade-off thật sự ở đây:
compile-time exhaustiveness mạnh hơn vs. type-checking performance tốt hơn
Điều đó cũng giúp giải thích vì sao các predicate tường minh như ast.isCallExpression() vẫn có vai trò quan trọng.
Còn một chi tiết riêng của TypeScript 7 đáng lưu ý.
Phần unstable trong:
typescript/unstable/ast
cũng rất quan trọng.
Mình sẽ không xây documentation promise quá cứng quanh một API surface vẫn đang thay đổi.
Composition pattern là generic.
Còn integration cụ thể với TypeScript v7 có thể tiếp tục thay đổi cùng TypeScript.
✋ Không phải AST check nào cũng cần cách này
Điều này cũng quan trọng.
Nếu mình chỉ có một local condition:
if (ts.isReturnStatement(node) && node.expression) {
// node: ts.ReturnStatement
// node.expression: ts.Expression
visit(node.expression);
}
mình sẽ giữ nguyên inline.
Thật đấy.
Việc biến nó thành:
const isReturnWithExpression = ...
chỉ vì có thể không tự động làm code tốt hơn.
Mình nghĩ cách chia hữu ích là:
| Tình huống | Nên ưu tiên |
|---|---|
| Một local branch | Native ts.isX check |
| AST shape lặp lại | Named reusable guard |
Project đã dùng is-kit |
refineKey, refineDefinedKey, refineIndex |
Mục tiêu không phải là:
Thay mọi điều kiện
ts.isXbằngis-kit.
Mà là:
Khi một runtime fact trở thành vocabulary có thể tái sử dụng, hãy giữ narrowing của nó cũng có thể tái sử dụng.
🚫 Điều này không cố gắng làm gì
is-kit không cố trở thành một Compiler API framework.
Nó không:
- wrap từng Compiler API function
- validate toàn bộ AST node shape
- kiểm soát AST traversal
- phát hiện AST cycle
- thêm TypeScript làm runtime dependency
- yêu cầu TypeScript làm peer dependency
- thay thế các inline check rõ ràng chỉ dùng một lần
Compiler API chỉ là một ví dụ thực tế, có độ phức tạp cao, của generic property refinement.
Đó là ranh giới mình muốn giữ.
🎯 Phần quan trọng nhất
Mình bắt đầu nghiên cứu với suy nghĩ:
Có lẽ TypeScript Compiler API cần một kiểu xử lý đặc biệt.
Nhưng điều mình tìm thấy lại tổng quát hơn.
Pattern lặp đi lặp lại thực sự là:
narrow parent
↓
check child
↓
preserve both facts
↓
reuse the predicate
Điều này hữu ích cho AST node, nhưng bản chất không thật sự là vấn đề AST.
Vì vậy, mental model hiện tại của mình là:
- dùng native type guard cho runtime knowledge thật sự
- giữ one-off condition ở dạng inline
- compose thành named guard khi cùng checked shape bắt đầu được tái sử dụng
- giữ child refinement trên parent thay vì tự viết lại intersection type
Với Compiler API, có thể trông như thế này:
const isCallWithIdentifierExpression = and(
ts.isCallExpression,
refineKey("expression", ts.isIdentifier),
);
Runtime check nhỏ.
Các mảnh ghép nhỏ, có thể tái sử dụng.
Và TypeScript giữ lại đúng những fact mà chúng ta thực sự đã kiểm tra. 😸
Mình cũng đã viết một guide chi tiết hơn với ví dụ về required child, optional child, array index, nested AST shape và TypeScript 7:
Advanced Property Refinement with the TypeScript Compiler API
https://is-kit.dev/guides/typescript-compiler-api
Nếu bạn muốn xem is-kit:
https://github.com/nyaomaru/is-kit
Nếu thấy hữu ích, một ⭐ trên GitHub luôn rất được hoan nghênh!
Cảm ơn bạn đã đọc! 🙌
All Rights Reserved