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
 
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
 
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