Hoi hoi! 👋
I'm @nyaomaru, a frontend engineer exploring new possibilities with Jev 😸 (I'm also curious about "Decisions API" from OpenAI)
Recentl...
For further actions, you may consider blocking this person and/or reporting abuse
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