The TypeScript compiler API does not automatically narrow the type of a child node before you use it. You must explicitly check the node’s kind (and often its parent context) to safely access type-specific properties. This article explains why narrowing is required, how to do it correctly, and common pitfalls to avoid.
Background: What the Compiler API Gives You
The TypeScript compiler exposes a rich API (in the typescript package) for parsing source code into an Abstract Syntax Tree (AST). Each node in the tree is an instance of a class that extends Node. The base Node type only guarantees a few properties like kind, pos, and end. To get meaningful data—such as the name of a function declaration or the type of a variable—you need to work with specific subclasses like FunctionDeclaration or VariableDeclaration.
The API gives you a kind enum that tells you what a node is. For example, SyntaxKind.FunctionDeclaration or SyntaxKind.VariableDeclaration. But the TypeScript compiler does not automatically refine the type of a variable just because you compared its kind. You have to perform the narrowing yourself, usually with a type guard or a switch statement.
This is by design: the compiler API is low-level and expects you to be explicit. It also means that if you try to access a property that only exists on a specific subclass without narrowing, TypeScript will throw a compile-time error, and at runtime your code may crash or return undefined.
How to Narrow a Child Node
There are several standard ways to narrow a node in the compiler API. The most common is to use a switch statement on node.kind. Inside each case, TypeScript’s control-flow analysis will narrow the type automatically.
function visit(node: ts.Node) {
switch (node.kind) {
case ts.SyntaxKind.FunctionDeclaration:
const fn = node as ts.FunctionDeclaration;
console.log(fn.name?.text);
break;
case ts.SyntaxKind.VariableDeclaration:
const decl = node as ts.VariableDeclaration;
console.log(decl.name.getText());
break;
}
}
Alternatively, you can use the type guard ts.isFunctionDeclaration(node). The TypeScript compiler API exports a set of these predicate functions for most node kinds. They are preferred because they are type-safe and readable:
if (ts.isFunctionDeclaration(node)) {
// node is now ts.FunctionDeclaration
console.log(node.name?.text);
}
You can also use as casts, but they are not safe—they bypass the type checker. Only use them when you are absolutely sure of the kind, and prefer guards or switches.
Why the Compiler Doesn’t Narrow Automatically
One might wonder why the API doesn’t just give you the right subclass directly. The reason is that the AST is a plain tree of Node objects. The compiler does not know at parse time what you intend to do with each node. It only knows the syntax. By keeping the base type broad, the API is flexible: you can write generic visitors that handle any node, and then narrow only when you need specific information.
Another reason is performance. The compiler is already doing a lot of work during parsing and type-checking. Automatically narrowing every node would add overhead. Instead, the API gives you the tools to do it efficiently.
Also, note that the kind property is a numeric enum. You could theoretically check it manually, but the provided type guards and switch statements are the idiomatic way.
Common Pitfalls and How to Avoid Them
- Using
ascasts without checking: This can lead to runtime errors if the node is not of the expected kind. Always checkkindor use a type guard first. - Forgetting that some nodes are optional: For example,
FunctionDeclaration.nameis of typeIdentifier | undefined. Always use optional chaining (?.text) or check forundefined. - Assuming a node’s parent is a certain type: The parent of a node is also a
Node. You need to narrow the parent as well if you need its properties. - Not handling all cases in a switch: The default case should at least do nothing, but it’s easy to forget a kind and then have
undefinedproperties. - Using
getChildren()and expecting a specific order: The order of children is not guaranteed by the API. Use named properties likenode.nameornode.typeinstead.
Practical Example: Visiting a Function’s Parameters
Suppose you want to extract the names of all parameters of a function declaration. You would start with a FunctionDeclaration node, then access its parameters property (which is a NodeArray<ParameterDeclaration>). Each parameter has a name property. Here’s how you would do it safely:
function visitFunction(node: ts.Node) {
if (!ts.isFunctionDeclaration(node)) return;
for (const param of node.parameters) {
// param is already narrowed to ParameterDeclaration
console.log(param.name.getText());
}
}
Note that param.name is of type BindingName (which can be an Identifier or an object binding pattern). If you only want simple identifiers, you might need to check ts.isIdentifier(param.name).
Alternatives and Best Practices
- Use the
ts.forEachChildfunction: This is a safe way to traverse children without manually managing recursion. It gives you each child as aNode, and you can narrow inside the callback. - Use the
ts.transpileModulefor simple transformations: If you only need to transpile code, you don’t need to manually narrow nodes. But for AST manipulation, you do. - Consider using a visitor pattern: Write a class that implements
ts.Visitorand override thevisitmethod. It still requires narrowing, but it keeps your code organized. - Use the
ts.factoryAPI to create new nodes: When building new AST nodes, use the factory functions to ensure correct kinds. This avoids manual narrowing when constructing nodes.
Frequently Asked Questions
Q: Can I use node.kind === ts.SyntaxKind.Identifier to check if a node is an identifier?
Yes, that works. But TypeScript’s control-flow analysis will not automatically narrow the type of the variable to Identifier just from that comparison. You still need to cast or use a type guard. The ts.isIdentifier function does both the check and the type narrowing.
Q: What is the difference between ts.isFunctionDeclaration and checking node.kind?
They are functionally equivalent. The ts.isFunctionDeclaration function is a type guard that checks the kind and also tells TypeScript to narrow the type. Using node.kind directly requires an explicit cast. Both are safe if used correctly, but the guard is more concise and less error-prone.
Q: Why do I get undefined when accessing node.name on a function declaration?
Because FunctionDeclaration.name is optional. A function can be anonymous (e.g., export default function() {}). Always use optional chaining or check for undefined before using the name.
Q: How do I get the parent of a node?
You can use node.parent. It is of type Node, so you need to narrow it as well. For example, to check if a function is inside a class, you would do if (node.parent && ts.isClassDeclaration(node.parent)).
Q: Is there a way to avoid narrowing altogether?
If you only need to traverse the AST and not extract specific data, you can use ts.forEachChild and ignore the node types. But for any real transformation or analysis, you will need to narrow at some point. There is no way around it.
Q: What is the best way to recursively visit all nodes?
Use a recursive function that calls ts.forEachChild on each node. Inside the callback, you can narrow each child as needed. This is the standard pattern in compiler API tools.