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.
I write about Next.js + TypeScript + AI engineering, with a focus on making sites discoverable to LLMs and AI search. Long-form notes at mudassirkhan.me
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?
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. 😸
I write about Next.js + TypeScript + AI engineering, with a focus on making sites discoverable to LLMs and AI search. Long-form notes at mudassirkhan.me
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?
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.
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
記事内の 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
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
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
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
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)
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
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
We're a place where coders share, stay up-to-date and grow their careers.
The moment you extract that compound check into a named function, TS flattens it to
booleanand 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 asnode 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.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 👇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
the jump from
node is Ttonode 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.refineKeysolves 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?Yeah, that’s why I split required and optional property refinement!
refineKeyis for a property that already exists on the parent type. For an optional child, there’srefineDefinedKey: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. 😸
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?
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
refineKeyas a small helper for expressing that missing relationship without writing the full intersection by hand every time.❤️
😸
Thx! 😸
the typescript part was especially useful
Thx! 😸
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
ascasts with runtime assertions.The
ts.isXxxfactory approach you showed is clean, but I've found it gets brittle when dealing with compound nodes (e.g.,VariableDeclarationinside aForInStatementvsVariableStatement). Have you experimented with a small visitor utility that re-applies narrowing based on parent context? Something like anarrowNode(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
--watchrebuilds — found it via LabAgent, site: labagent .techTypeScript 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 .techTypeScript 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.isXxxkế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ùngts.forEachChildlọ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ớiVariableDeclarationcó initializer phức tạp (ví dụ ternary expression mà TS không narrow được implicit type). Có thử apply pattern này choJSDoctype annotation chưa? Thỉnh thoảng gặp case TS ignore JSDoc@typetag khi narrow trong guard PS: the tool I meant is on labagent .techViệ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
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.isIdentifierkết hợp vớigetTypeOfSymbolAtLocationđể 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 SomeSpecificNodethườ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ùngassertssignature, 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-morphwrapper 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 .techTypeScript 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 Tthay 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
ExpressionthànhBinaryExpressionrồi accessleft/right— thì TypeScript 5.5+ đã cải thiệnispredicate 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 returnExtract<...>để 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)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.isNodeKindkết hợp vớits.forEachChildđể traverse an toàn thay vì cast thủ công. Còn patterntype Guard<T> = (node: Node) => node is Tkết hợp vớits.factory.createTypePredicateNodegiú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ộtassertssignature hoặc dùngts.NarrowingCallback(nếu target ES2022+). Có ai thử approach này chưa? — found it via LabAgent, site: labagent .techThe 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
forEachormapcallback, TypeScript still widens the type because it can't track the control flow across the callback boundary. A workaround I've used is threading aconstassertion through areducethat 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