keepcalculator

06 Numbers and Powers

TypeScript Compiler API: Narrow Each Child Before Use?

Card No. 06·66436 · Updated · Standard formula, shown below

Average pace 5.00 min/km
Average speed
12.00 km/h
Result
Finish time
50 min
You entered
Distance
10 km
You entered
You enter
Finish time, Distance
You get
Average pace, Average speed
Rounding
2 dp

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 as casts without checking: This can lead to runtime errors if the node is not of the expected kind. Always check kind or use a type guard first.
  • Forgetting that some nodes are optional: For example, FunctionDeclaration.name is of type Identifier | undefined. Always use optional chaining (?.text) or check for undefined.
  • 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 undefined properties.
  • Using getChildren() and expecting a specific order: The order of children is not guaranteed by the API. Use named properties like node.name or node.type instead.

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.forEachChild function: This is a safe way to traverse children without manually managing recursion. It gives you each child as a Node, and you can narrow inside the callback.
  • Use the ts.transpileModule for 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.Visitor and override the visit method. It still requires narrowing, but it keeps your code organized.
  • Use the ts.factory API 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.

Related

Formula used
in t | Finish time | min | 50 | 0
in d | Distance | km | 10 | 0 | 100000
out pace | Average pace | min/km | t / d | 2
out speed | Average speed | km/h | d / (t / 60) | 2

Each line is one input (in, sel, list) or one result (out: label, unit, formula, decimals). How the calculators work

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →