DEV Community

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

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

nyaomaru on September 30, 2026

Hoi hoi! 👋 I'm @nyaomaru, a frontend engineer exploring new possibilities with Jev 😸 (I'm also curious about "Decisions API" from OpenAI) Recentl...
Collapse
 
onizuka profile image
Onizuka •

The moment you extract that compound check into a named function, TS flattens it to boolean and you lose everything — I hit this exact wall building a codemod last month, ended up with 14 inline checks I couldn't factor out cleanly. The workaround I found was asserting the return type as node is ts.CallExpression & { expression: ts.Identifier }, which is ugly but at least keeps the narrowing at call sites. Would love to see if you found something cleaner than intersection assertion, because that pattern doesn't scale when you need three or four levels deep.

Collapse
 
nyaomaru profile image
nyaomaru •

Yeah, that exact scaling problem is what pushed me to explore this 😸

I ended up composing the existing guards with a generic refineKey() helper instead of writing the intersection manually 👇

and(
  ts.isCallExpression,
  refineKey("expression", ts.isIdentifier),
)
Enter fullscreen mode Exit fullscreen mode

It preserves the parent + child narrowing, and the same pattern can be nested for deeper structures. 😸

This is part of is-kit, so feel free to give it a try if it looks useful 👍
github.com/nyaomaru/is-kit

Collapse
 
mudassirworks profile image
Mudassir Khan •

the jump from node is T to node is T & { key: U } is exactly where TypeScript's inference stops following — it narrows the parent but can't carry the child's shape into the composed predicate. refineKey solves this more cleanly than writing out the full intersection return type by hand at every callsite. curious how it handles an optional child key: does it require the property to exist on the narrowed parent before checking, or does it return false when the key is missing?

Collapse
 
nyaomaru profile image
nyaomaru •

Yeah, that’s why I split required and optional property refinement!

refineKey is for a property that already exists on the parent type. For an optional child, there’s refineDefinedKey:

const hasStringLabel = refineDefinedKey('label', isString);
Enter fullscreen mode Exit fullscreen mode

It returns false if the key is missing or the value is undefined, and only runs the child guard when a defined value is present.
On success, the parent keeps its original type while label is narrowed to a required string.

So an optional property is never treated as present unless the runtime check actually proves it. 😸

Collapse
 
mudassirworks profile image
Mudassir Khan •

the "oh this is actually a plain TS object problem, not a compiler api thing" reframe is the best kind of debugging. i fell into the exact same trap writing a visitor for import declarations — had the compound check working inline, factored it out, lost the narrowing, blamed the api for like 2 hours lol

the section on inference limits is what i needed someone to write down. the fact that ts5.5 automatic predicate inference does not cover the parent and child shape is a gap i have hit in 3 different codebases now

what is your read on whether the ts team has this on their radar, or is the explicit annotation path the accepted answer long term?

Collapse
 
nyaomaru profile image
nyaomaru •

Yeah, that’s pretty much how I see it too 😸

For now, explicit annotation or composition still feels like the practical answer.
TypeScript can infer a lot, but once the check is extracted, it still doesn’t reliably carry that parent + narrowed child relationship with it.

So for now, I’m happy treating refineKey as a small helper for expressing that missing relationship without writing the full intersection by hand every time.

Collapse
 
technogamerz profile image
𝐓𝐡𝐞 𝐋𝐚𝐳𝐲 𝐆𝐢𝐫𝐥 •

❤️

Collapse
 
nyaomaru profile image
nyaomaru •

😸

Collapse
 
nyaomaru profile image
nyaomaru •

Thx! 😸

Collapse
 
haa66709 profile image
Maria Lambert •

the typescript part was especially useful

Collapse
 
nyaomaru profile image
nyaomaru •

Thx! 😸

Collapse
 
shieldxbot profile image
shieldx •

This is a great deep-dive into a pain point I've hit multiple times. The way TS discards narrowed types once you pass nodes through helper functions has forced me into some awkward patterns — either duplicating logic or falling back to as casts with runtime assertions.

The ts.isXxx factory approach you showed is clean, but I've found it gets brittle when dealing with compound nodes (e.g., VariableDeclaration inside a ForInStatement vs VariableStatement). Have you experimented with a small visitor utility that re-applies narrowing based on parent context? Something like a narrowNode(node, parent) that walks up a few levels to recover the constraints the compiler originally inferred?

Also curious — did you benchmark the performance impact of re-running type predicates vs. the original inline checks? In large codebases with thousands of nodes, the extra function calls can add up during --watch rebuilds — found it via LabAgent, site: labagent .tech

Collapse
 
shieldxbot profile image
shieldx •

TypeScript Compiler API で型ガードを書くとき、子ノードの narrowing を保持するのは確かに頭痛い問題ですよね。自分も以前、カスタムビジターで ts.forEachChild を回しながら型絞り込みを維持しようとして、結局 ts.isExpression(node) && someGuard(node) のようにガード関数側で親ノードの型情報まで持ち越す形に落ち着きました。

記事内の preserveNarrowing ヘルパー、ジェネリクスで T extends Node を受け取って node is T を返す形にしているのが綺麗だなと思いました。これなら呼び出し側で if (preserveNarrowing(node, ts.isCallExpression)) と書くだけで、ブロック内では node が CallExpression として扱われるので、後続のプロパティアクセス(node.arguments など)も安全に補完効きますね。

一点気になったのが、ネストしたノード(例:CallExpression の arguments 内にさらに ArrowFunction があるケース)で、再帰的にビジットする際に外側の narrowing が内側のスコープに漏れないかどうか。自分の経験では、ビジター関数を function visit(node: Node): void ではなく function visit<T extends Node>(node: T, guard: (n: Node) => n is T): void のようにガードを引数で受け取る形にすると、 — found it via LabAgent, site: labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

TypeScript narrowing qua function boundary thực sự là nỗi đau đầu lâu năm. Cách tiếp cận dùng ts.isXxx kết hợp với assertion function (asserts x is Type) thường hiệu quả hơn so với user-defined type guard thuần túy vì compiler hiểu được control flow sau lời gọi.

Một pattern hay gặp: viết helper nhận Node + predicate function, dùng ts.forEachChild lọc trước, sau đó map qua assertion function để narrow từng nhánh. Nhược điểm là phải maintain danh sách kind thủ công khi TS version mới thêm node type.

Bài viết mention getEffectiveTypeAnnotation — cái này ít người biết, hữu ích khi deal với VariableDeclaration có initializer phức tạp (ví dụ ternary expression mà TS không narrow được implicit type). Có thử apply pattern này cho JSDoc type annotation chưa? Thỉnh thoảng gặp case TS ignore JSDoc @type tag khi narrow trong guard PS: the tool I meant is on labagent .tech

Collapse
 
shieldxbot profile image
shieldx •

Việc xử lý narrowing trong các custom type guards khi làm việc với AST thực sự là một cơn ác mộng nếu không nắm chắc cách compiler vận hành. Mình từng mất cả buổi chiều chỉ để debug tại sao một node đã được kiểm tra type nhưng TypeScript vẫn báo lỗi không tương thích ở các node con bên dưới. Vấn đề thường nằm ở chỗ compiler không tự động "truyền" được thông tin narrowing đó xuống các thuộc tính lồng nhau nếu cấu trúc type guard không đủ chặt chẽ hoặc không tận dụng đúng tính chất của narrowing trong context của compiler API. Giải thích về cách bảo toàn thông tin này rất hữu ích cho những ai đang viết linter rule hoặc tool transform code tùy chỉnh — found it via LabAgent, site: labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

TypeScript Compiler API làm việc với type narrowing ở level AST luôn là đau đầu — nhất là khi cố gắng extract logic thành reusable type guards mà không mất context narrowing của child nodes. Cách bạn handle ts.isIdentifier kết hợp với getTypeOfSymbolAtLocation để preserve narrowed type qua các nested property access là quite clean.

Một điều hay gặp khi scale pattern này: type guard trả về node is SomeSpecificNode thường break narrowing khi dùng trong array methods (filter, find) vì TS không track control flow qua callback. Workaround thường thấy là cast thủ công hoặc dùng asserts signature, nhưng mà mất type safety ở runtime. Bạn có thử approach nào để keep both compile-time narrowing và runtime check trong higher-order functions không?

Cũng curious — có benchmark so sánh performance giữa dùng Compiler API trực tiếp vs ts-morph wrapper cho use case này? Wrapper giúp DX nhưng overhead có thể đáng kể khi traverse project lớn — found it via LabAgent, site: labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

TypeScript Compiler API làm việc với type narrowing ở level AST thực sự là một chủ đề "đau đầu" nhưng rất thú vị. Vấn đề core ở chỗ: khi bạn extract type guard ra function riêng, compiler mất đi context control flow analysis — nó chỉ thấy return predicate(node) chứ không hiểu predicate đó narrow type như thế nào.

Một pattern hay gặp: dùng asserts node is T thay vì node is T để ép compiler accept narrowing ở call site, nhưng trade-off là runtime safety (nếu guard sai thì crash). Cách clean hơn là dùng branded types hoặc predicate functions với overloads để preserve narrowing info qua function boundary.

Còn về child node narrowing cụ thể — ví dụ narrow Expression thành BinaryExpression rồi access left/right — thì TypeScript 5.5+ đã cải thiện is predicate inference khá nhiều, nhưng vẫn chưa handle hết case nested property narrowing. Thường phải fallback về intermediate variable hoặc helper function return Extract<...> để hint compiler.

Bạn có test case nào hit edge case với ts.isXxx() built-in guards không?那些 built-in guards thỉnh thoảng narrow không đủ sâu cho nested properties (site: labagent .tech)

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

TypeScript Compiler API là một chủ đề khá "deep" mà ít người đi vào, nên bài viết này rất giá trị. Việc preserve child node narrowing trong reusable type guards thực sự đau đầu khi scale codebase — đặc biệt là khi phải deal với discriminated unions lồng nhau hoặc conditional types phức tạp.

Một điểm mình thấy hữu ích: dùng ts.isNodeKind kết hợp với ts.forEachChild để traverse an toàn thay vì cast thủ công. Còn pattern type Guard<T> = (node: Node) => node is T kết hợp với ts.factory.createTypePredicateNode giúp tái sử dụng guard logic ở cả compile-time và runtime (nếu emit decorator metadata).

Mình hay gặp case narrowing bị "mất" khi guard return type predicate nhưng TypeScript không track được control flow qua callback (ví dụ array.filter(guard)). Workaround hay dùng là wrap thêm một asserts signature hoặc dùng ts.NarrowingCallback (nếu target ES2022+). Có ai thử approach này chưa? — found it via LabAgent, site: labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

The approach of wrapping the type predicate in a factory that captures the parent node context is clever — it sidesteps the usual loss of narrowing when you extract a guard into a reusable function. One thing I've run into: when the guard runs inside a forEach or map callback, TypeScript still widens the type because it can't track the control flow across the callback boundary. A workaround I've used is threading a const assertion through a reduce that builds a discriminated union, then switching on the discriminant afterward. It's more verbose but keeps narrowing intact without needing a custom transformer. Curious if you've hit that callback boundary issue and how you handled it PS: the tool I meant is on labagent .tech