0

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é! 👀

Image description


🌲 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à CallExpression không?
  • expression của nó có phải là Identifier khô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
  • filter
  • find
  • 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 initializer tồ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 0 tồ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à:

  • body tồn tại
  • body là 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.isX bằng is-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

Viblo
Let's register a Viblo Account to get more interesting posts.