【硬核实战】TypeScript Compiler API: Preserving Child Node Narrowing in Reusable Type Guards 🔧 (2026-10-03)

TypeScript Compiler API: Preserving Child Node Narrowing in Reusable Type Guards 🔧

在现代软件工程与前端架构演进中,掌握深层原理与实战编码技巧是提升技术壁垒的核心。本文将结合真实业务场景,通过完整代码实现与底层机制拆解,带你全面攻克这一核心技术点。


Hoi hoi! 👋

I'm @nyaomaru, a frontend engineer exploring new possibilities with Jev 😸 (I'm also curious about "Decisions API" from OpenAI)

Recently, I was looking into the TypeScript Compiler API and asking a pretty specific question

Can reusable type guards preserve not only the AST node type, but also a narrowed child property?

At first, I thought this might be a Compiler API-specific problem.

Then I reproduced the same pattern with plain TypeScript objects.

That changed the way I looked at it.

The interesting problem wasn't really the AST.

It was property refinement.

Let's look at it! 👀

🌲 A Very Common Compiler API Pattern

Suppose we have a broad ts.Node.

ts 复制代码
import * as ts from "typescript";

declare const node: ts.Node;

We want to know two things:

  • Is this a CallExpression?
  • Is its expression an Identifier?

Inline, this is easy.

ts 复制代码
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
  // node: ts.CallExpression
  // node.expression: ts.Identifier
  node.expression.text;
}

TypeScript understands the control flow perfectly.

Nothing special is needed.

And honestly, if this check appears only once, I would probably leave it exactly like this.

🤔 What If We Want to Reuse That Shape?

Now suppose the same AST shape appears in several places:

  • a visitor
  • filter
  • find
  • another transformation
  • another lint rule

Then giving the check a name starts to make sense.

Since TypeScript v5.5, simple functions can often infer a type predicate automatically.

But this compound parent-plus-child check is different.

ts 复制代码
const isCallWithIdentifierExpression = (node: ts.Node) =>
  ts.isCallExpression(node) && ts.isIdentifier(node.expression);
// inferred:
// (node: ts.Node) => boolean

So if we want the extracted predicate to preserve both facts, we have to describe the refined type explicitly.

ts 复制代码
const isCallWithIdentifierExpression = (
  node: ts.Node,
): node is ts.CallExpression & {
  expression: ts.Identifier;
} => ts.isCallExpression(node) && ts.isIdentifier(node.expression);

This works. Then we can reuse it

ts 复制代码
declare const nodes: readonly ts.Node[];

const calls = nodes.filter(isCallWithIdentifierExpression);
// calls:
// Array<
//   ts.CallExpression & {
//     expression: ts.Identifier;
//   }
// >

So what's the problem?

There isn't really a runtime problem.

The annoying part is that we had to manually describe this 👇

ts 复制代码
ts.CallExpression & {
  expression: ts.Identifier;
}

But we already performed those exact runtime checks.

It would be nice if we could compose the checks and let the type follow them. 😸

🧩 Refining the Parent and Child Together

This is where I ended up using refineKey.

With is-kit

ts 复制代码
import * as ts from "typescript";
import { and, refineKey } from "is-kit";

const isCallWithIdentifierExpression = and(
  ts.isCallExpression,
  refineKey("expression", ts.isIdentifier),
);

That's it.

Now

ts 复制代码
declare const node: ts.Node;

if (isCallWithIdentifierExpression(node)) {
  // node:
  // ts.CallExpression & {
  //   expression: ts.Identifier;
  // }
  node.expression.text;
}

The interesting part is the relationship between the two checks.

ts 复制代码
ts.isCallExpression;

narrows the parent.

Then

ts 复制代码
refineKey("expression", ts.isIdentifier);

within the composed narrowing chain, checks one property on the parent already narrowed by ts.isCallExpression and preserves the checked child type.

So the idea is

Check the child once at runtime, then carry that same fact back to the parent type.

😸 This Turned Out Not to Be an AST Problem

This was the part that surprised me during the research.

I originally thought I was investigating a TypeScript Compiler API gap.

But the same shape appears with ordinary objects too.

Conceptually, the pattern is just

ts 复制代码
Parent
  ↓
check property
  ↓
Parent & {
  property: RefinedChild
}

The Compiler API is simply a really good stress test for it because AST code contains this pattern everywhere.

For example

text 复制代码
CallExpression
  → expression
  → Identifier

or

text 复制代码
VariableDeclaration
  → initializer?
  → CallExpression

or

text 复制代码
CallExpression
  → arguments[0]
  → StringLiteral

So I don't think of refineKey as a Compiler API helper.

The Compiler API is just an advanced example of a more generic composition problem.

🔗 Compiler API Guards Already Compose Well

Another thing I wanted to avoid was wrapping TypeScript's existing predicates unnecessarily.

The Compiler API already provides excellent guards

ts 复制代码
ts.isStringLiteral;
ts.isIdentifier;
ts.isCallExpression;
ts.isClassDeclaration;

We should reuse them.

For example

ts 复制代码
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
// )[]

There is no reason for is-kit to create its own

ts 复制代码
isTsStringLiteral();
isTsIdentifier();
isTsCallExpression();

That would just duplicate the Compiler API.

The useful part is composition.

♻️ Reuse the Same Guard in find and Visitors

This becomes more useful when a refined shape appears in several contexts.

For example

ts 复制代码
import * as ts from "typescript";
import { and, refineKey } from "is-kit";

const isIdentifierNamedJsxAttribute = and(
  ts.isJsxAttribute,
  refineKey("name", ts.isIdentifier),
);

We can use it with find:

ts 复制代码
declare const attributes: readonly ts.JsxAttributeLike[];

const attribute = attributes.find(isIdentifierNamedJsxAttribute);
// attribute:
// (
//   ts.JsxAttribute & {
//     name: ts.Identifier;
//   }
// ) | undefined

The same guard works in a visitor

ts 复制代码
function visit(node: ts.Node): void {
  if (isIdentifierNamedJsxAttribute(node)) {
    // node:
    // ts.JsxAttribute & {
    //   name: ts.Identifier;
    // }
    node.name.text;
  }
  ts.forEachChild(node, visit);
}

That's the point where extracting the guard starts earning its keep.

The runtime rule and the TypeScript narrowing travel together.

🫥 Optional Children Are a Different Contract

AST nodes contain lots of optional properties.

For example, a VariableDeclaration may or may not have an initializer.

ts 复制代码
declaration.initializer;

So this is slightly different from refining a required property.

We don't just want

Refine initializer.

We want

Require initializer to exist, then refine it.

For that case, is-kit has refineDefinedKey.

ts 复制代码
import * as ts from "typescript";
import { refineDefinedKey } from "is-kit";

const hasCallInitializer = refineDefinedKey("initializer", ts.isCallExpression);

Now

ts 复制代码
declare const declaration: ts.VariableDeclaration;

if (hasCallInitializer(declaration)) {
  // declaration.initializer: ts.CallExpression
  declaration.initializer.expression;
}

Inside the branch, initializer is both:

  • present
  • a ts.CallExpression

A missing initializer returns false.

An explicitly undefined initializer also returns false.

I like keeping this separate from refineKey because absence is runtime behavior, not just a TypeScript annotation.

📦 Arrays Have the Same Problem

AST arrays introduce another small issue.

Suppose we want a call whose first argument is a string literal.

This

ts 复制代码
node.arguments[0];

looks simple, but at runtime the array can be empty.

So we want to prove two things:

  • index 0 exists
  • the value is a StringLiteral

We can compose that too

ts 复制代码
import * as ts from "typescript";
import { and, refineIndex, refineKey } from "is-kit";

const isCallWithStringFirstArgument = and(
  ts.isCallExpression,
  refineKey("arguments", refineIndex(0, ts.isStringLiteral)),
);

Then

ts 复制代码
declare const node: ts.Node;

if (isCallWithStringFirstArgument(node)) {
  // node: ts.CallExpression
  // node.arguments[0]: ts.StringLiteral
  node.arguments[0].text;
}

Now index 0 is known to exist and to be a ts.StringLiteral.

Again, this isn't really an AST-specific idea.

It's just

Refine one checked location and preserve that fact.

🪆 Nested Checks Can Stay Composable

These refinements can also be nested.

Suppose we want a function-like declaration whose:

  • body exists
  • body is a block
  • first statement exists
  • first statement is a return statement

We can build the pieces separately.

ts 复制代码
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,
);

Then

ts 复制代码
declare const functionLike: ts.FunctionLikeDeclaration;

if (hasBodyStartingWithReturn(functionLike)) {
  // functionLike.body: ts.Block
  // functionLike.body.statements[0]: ts.ReturnStatement
  functionLike.body.statements[0].expression;
}

Each step proves one thing.

There is no path string like

text 复制代码
body.statements[0]

and no special AST DSL.

It's just small guards composed together.

🔒 Why Only One Concrete Key or Index?

There is an important limitation here.

A successful lookup proves one concrete location.

If we check

ts 复制代码
refineKey("expression", ...)

we proved something about

ts 复制代码
parent.expression;

We did not prove that every property from some wider key domain passed the same test.

That's why the refinement helpers intentionally work with one concrete key or index.

Broad key unions and similar multi-location claims would make the resulting type much easier to overstate.

I would rather make the API slightly less magical than let one runtime lookup claim more than it actually checked.

🧪 What About TypeScript 7?

This research became especially interesting because TypeScript v7 changed the Compiler API landscape.

The examples in this section were verified against TypeScript v7.0.2.

As of TypeScript v7.0.2, AST types and predicates are exposed through

text 复制代码
typescript/unstable/ast

So the same composition style can be used there

ts 复制代码
import * as ast from "typescript/unstable/ast";
import { and, refineKey } from "is-kit";

const isCallWithIdentifierExpression = and(
  ast.isCallExpression,
  refineKey("expression", ast.isIdentifier),
);

One thing I specifically investigated was whether TypeScript v7 made these isX checks unnecessary through kind narrowing.

For a genuine discriminated union, TypeScript can absolutely narrow from a literal discriminant.

But the broad AST Node currently exposed by the TypeScript v7 AST surface is not that kind of closed discriminated union.

So with a broad AST node, the isX predicates still matter.

For example

ts 复制代码
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
}

This distinction matters.

A custom AST type modeled as a discriminated union can behave differently.

That doesn't mean TypeScript v7's broad Node currently behaves the same way.

Why Not Make Node a Closed Union?

After I posted about this, Jake Bailey gave a wonderfully concise answer:

because it's slow 😞

{% embed bsky.app/profile/jak... %}

That makes the trade-off much easier to understand.

If Node were one closed discriminated union containing every AST node type, kind could potentially give us stronger narrowing and exhaustive checks.

For example, with a closed union, we can use the familiar never pattern

ts 复制代码
switch (node.kind) {
  // handle every known kind...

  default: {
    const exhaustive: never = node;
  }
}

When a new variant is added, that never check can fail at compile time and tell us that our handling is no longer exhaustive.

But that stronger type-level model is not free.

The cost is type-checking performance, a very large closed union gives the checker more work to do.

So the broad shape of ast.Node is not simply a missing narrowing feature.

There is a real trade-off here

stronger compile-time exhaustiveness vs. type-checking performance

And that also helps explain why explicit predicates like ast.isCallExpression() still have an important role.

There is one more TypeScript 7-specific detail worth keeping in mind.

The unstable part of

text 复制代码
typescript/unstable/ast

is also important.

I wouldn't build documentation promises around a still-evolving API surface.

The composition pattern is generic.

The exact TypeScript v7 integration can evolve with TypeScript itself.

✋ You Probably Don't Need This for Every AST Check

This is also important.

If I have one local condition

ts 复制代码
if (ts.isReturnStatement(node) && node.expression) {
  // node: ts.ReturnStatement
  // node.expression: ts.Expression
  visit(node.expression);
}

I would leave it inline.

Really.

Turning it into

ts 复制代码
const isReturnWithExpression = ...

just because we can doesn't automatically make the code better.

I think the useful split is

Situation Prefer
One local branch Native ts.isX checks
Repeated AST shape Named reusable guard
Project already uses is-kit refineKey, refineDefinedKey, refineIndex

The goal isn't

Replace every ts.isX condition with is-kit.

The goal is

When a runtime fact becomes reusable vocabulary, keep the narrowing reusable too.

🚫 What This Does Not Try to Do

is-kit doesn't try to become a Compiler API framework.

It does not:

  • wrap individual Compiler API functions
  • validate complete AST node shapes
  • control AST traversal
  • detect AST cycles
  • add a TypeScript runtime dependency
  • require TypeScript as a peer dependency
  • replace clear one-off inline checks

The Compiler API is simply a demanding real-world example of generic property refinement.

That's the boundary I want to keep.

🎯 The Important Part

I started this research thinking

Maybe the TypeScript Compiler API needs some special handling.

What I found was more general.

The recurring problem was

text 复制代码
narrow parent
    ↓
check child
    ↓
preserve both facts
    ↓
reuse the predicate

That's useful for AST nodes, but it isn't really about AST nodes.

So my current mental model is:

  • use native type guards for the actual runtime knowledge
  • keep one-off conditions inline
  • compose a named guard when the same checked shape becomes reusable
  • preserve child refinement on the parent instead of rewriting intersection types by hand

For the Compiler API, that can look like

ts 复制代码
const isCallWithIdentifierExpression = and(
  ts.isCallExpression,
  refineKey("expression", ts.isIdentifier),
);

Small runtime checks. Small reusable pieces. And TypeScript keeps the facts we actually checked. 😸

I also wrote a more detailed guide with examples for required children, optional children, array indices, nested AST shapes, and TypeScript 7

Advanced Property Refinement with the TypeScript Compiler API

{% embed is-kit.dev/guides/type... %}

And if you want to explore is-kit itself

{% embed github.com/nyaomaru/is... %}

If it looks useful, a ⭐ on GitHub is always very welcome!

Thanks for reading! 🙌


💡 核心总结与实践建议

代码的优雅不仅体现在语法的精练,更在于对底层执行机制、边界条件与异常处理的深度把握。在实际项目中落地类似功能时,建议结合自身业务的数据流规模与性能指标(如 LCP、INP 等)进行综合压测评估。

你在日常项目中遇到过类似的技术挑战吗?大家通常是如何优化和解构的?欢迎在评论区分享你的实战经验! 💬🚀

相关推荐
计算机魔术师37 分钟前
Anthropic 密会宗教领袖谈 Claude 意识,OpenAI 为什么急眼
前端
xieter41 分钟前
【硬核实战】React 19 useFormStatus Returning False? I Built a SubmitButton That Fixes It (2026-10-03)
前端·javascript
Data analyse4562 小时前
Cookie过期引发UV上涨的复核方法
服务器·前端·数据分析·uv
paopaokaka_luck3 小时前
非遗文物数字化小程序(AI非遗问答、ONNX图像识别、协同过滤推荐、ECharts数据分析、非遗知识浏览与互动、文创商城订单闭环、文化活动报名签到、社区交流)
javascript·spring boot·mysql·数据分析·echarts·mybatis
Highcharts.js3 小时前
图表开发实战总结| 7 条“黄金法则”与“性能铁律”
前端·javascript·vue.js·编辑器·时序数据库·highcharts
Helix2503 小时前
Electron、WebView 框架与 HTA 对比:桌面应用选型、安全性与现代替代方案
前端·javascript·electron·webview·vbscript·hta·本地html应用
Ikalus19883 小时前
三套 API 都告诉我成功了,浏览器一个字都没收到
javascript
开开心心就好3 小时前
办公软件卸载不干净?专用工具一键清残留
java·前端·人工智能·智能手机·kafka·excel·memcache
Ikalus19883 小时前
计数器停在 0,我差点把一篇空摘要发出去
javascript