
Advanced Property Refinement with the TypeScript Compiler API
Use broad AST nodes as a demanding example of reusable property refinement: narrow the node with isX, then preserve checked child facts on the parent.
TypeScript 7 does not make broad AST nodes a discriminated union
TypeScript can narrow a genuine discriminated union through a kind comparison. The broad Node and child types currently shipped by the TypeScript 7 AST API are base interfaces instead, so the same comparison does not recover a concrete node type.
import * as ast from 'typescript/unstable/ast';declare const node: ast.Node;if (node.kind === ast.SyntaxKind.CallExpression) {// A broad ast.Node is a base interface, not a discriminated union.// The kind comparison does not expose CallExpression properties.// @ts-expect-error: expression is not available on ast.Nodenode.expression;}// TypeScript can narrow a union whose members declare distinct literal kinds.// That is a different input model from the shipped broad ast.Node type.
For broad AST inputs,ast.isCallExpression,ast.isIdentifier, and the otherisXpredicates still perform the node narrowing.
A custom generated AST union can behave differently. That proves the benefit of a narrower input model; it does not describe the broad AST types shipped by TypeScript 7.
The quick answer
First use a Compiler API isX guard to narrow the broad node. Then use refineKey when a required child node must also be narrowed and the compound predicate should be reusable.
import * as ts from 'typescript';import { and, refineKey } from 'is-kit';const isCallWithIdentifierExpression = and(ts.isCallExpression,refineKey('expression', ts.isIdentifier),);declare const node: ts.Node;if (isCallWithIdentifierExpression(node)) {node.expression.text;// node: ts.CallExpression & { expression: ts.Identifier }}
ts.isCallExpression narrows the node. refineKey then applies ts.isIdentifier to its required expression property and carries that fact back to the parent type.
Check the child once at runtime, then preserve the same checked fact on the parent in TypeScript control flow.
This is not an AST-specific feature. It is the same generic property refinement described in Refine properties on existing TypeScript types; the Compiler API is an advanced use case.
Compose Compiler API refinements directly
Compiler API guards accept ts.Node, not arbitrary unknown. is-kit logic combinators preserve that known input domain, so wrappers and assertion casts are unnecessary.
import * as ts from 'typescript';import { oneOf, or } from 'is-kit';const isStringLike = or(ts.isStringLiteral,ts.isNoSubstitutionTemplateLiteral,);const isNamedDeclaration = oneOf(ts.isClassDeclaration,ts.isFunctionDeclaration,ts.isVariableDeclaration,);declare const nodes: readonly ts.Node[];const strings = nodes.filter(isStringLike);// (ts.StringLiteral | ts.NoSubstitutionTemplateLiteral)[]const declarations = nodes.filter(isNamedDeclaration);// (ts.ClassDeclaration | ts.FunctionDeclaration | ts.VariableDeclaration)[]
The resulting functions remain ordinary type predicates. Reuse them in branches, visitors, filter, find, or other APIs that understand TypeScript predicates.
Reuse a refined node in find and visitors
A named guard can move between collection methods and AST traversal without losing its refined child type. This is especially useful for repeated JSX attribute checks.
import * as ts from 'typescript';import { and, refineKey } from 'is-kit';const isIdentifierNamedJsxAttribute = and(ts.isJsxAttribute,refineKey('name', ts.isIdentifier),);declare const attributes: readonly ts.JsxAttributeLike[];const attribute = attributes.find(isIdentifierNamedJsxAttribute);// (ts.JsxAttribute & { name: ts.Identifier }) | undefinedfunction visit(node: ts.Node): void {if (isIdentifierNamedJsxAttribute(node)) {node.name.text;}ts.forEachChild(node, visit);}
The same predicate narrows the result returned by find and the current node inside the visitor branch.
Choose the child contract explicitly
| Helper | Child contract | Failure before refinement |
|---|---|---|
| refineKey | Required property | None; the property belongs to the input contract |
| refineDefinedKey | Optional property that must be defined | Missing or undefined value |
| refineIndex | One readonly-array element | Out of bounds, sparse hole, or undefined value |
These are separate APIs because absence is runtime behavior, not only a type annotation. The helper name tells readers whether missing data is outside the contract or should make the predicate return false.
Require and refine an optional child
Compiler API declarations commonly expose optional children such as a variable initializer or method body. Use refineDefinedKey when absence should fail safely.
import * as ts from 'typescript';import { refineDefinedKey } from 'is-kit';const hasCallInitializer = refineDefinedKey('initializer',ts.isCallExpression,);declare const declaration: ts.VariableDeclaration;if (hasCallInitializer(declaration)) {declaration.initializer.expression;// initializer is present and is ts.CallExpression}
Missing and explicitly undefined initializers return false. They are never passed to the narrow-domain Compiler API refinement.
Refine one array element
Node arrays can be empty at runtime even when unchecked indexed access makes arguments[0] look defined. Use refineIndex to make the element check explicit.
import * as ts from 'typescript';import { and, refineIndex, refineKey } from 'is-kit';const isCallWithStringFirstArgument = and(ts.isCallExpression,refineKey('arguments', refineIndex(0, ts.isStringLiteral)),);declare const node: ts.Node;if (isCallWithStringFirstArgument(node)) {node.arguments[0].text;// arguments[0] is present and is ts.StringLiteral}
Index 0 is narrowed only after the value exists and the supplied node refinement succeeds.
Compose nested child checks
Build deeper checks one property or index at a time. A path DSL is not required to preserve each intermediate parent type.
import * as ts from 'typescript';import { and, refineDefinedKey, refineIndex, refineKey } from 'is-kit';const isBlockStartingWithReturn = and(ts.isBlock,refineKey('statements', refineIndex(0, ts.isReturnStatement)),);const hasBodyStartingWithReturn = refineDefinedKey('body',isBlockStartingWithReturn,);declare const method: ts.MethodDeclaration;if (hasBodyStartingWithReturn(method)) {method.body.statements[0].expression;// body is present, is a block, and starts with a return statement}
This composition remains safe with exactOptionalPropertyTypes and noUncheckedIndexedAccess enabled.
Use one concrete key or index
The key and index helpers accept one concrete runtime location. Broad keys, unions, template-literal patterns, and branded multi-value key domains are rejected at compile time.
One successful lookup proves one property. It cannot soundly claim that every property in a wider key domain passed the refinement.
Use composition where it earns its keep
While designing this API, we reviewed active open-source parsers, linters, and transforms built on the Compiler API. The same nested narrowing shapes appeared repeatedly: calls with identifier expressions, declarations with defined initializers, and calls with a checked argument.
Local prototypes confirmed that is-kit can preserve those checks as reusable parent refinements. They also showed that adding a dependency is not automatically worthwhile. Choose the smallest approach that matches how often the predicate is reused.
| Situation | Prefer | Reason |
|---|---|---|
| One local branch | Native ts.isX predicates | The inline condition is already the clearest expression |
| Repeated or reusable AST shape | A named is-kit composition | Reuse the runtime check and its parent narrowing together |
| The project already uses is-kit | refineKey, refineDefinedKey, or refineIndex | Keep nested narrowing consistent without another dependency |
import * as ts from 'typescript';declare const node: ts.Node;declare function visit(node: ts.Node): void;// Keep a one-off local branch inline when no reusable guard is needed.if (ts.isReturnStatement(node) && node.expression) {visit(node.expression);}
Use is-kit to build reusable type vocabulary, not to replace every clear ts.isX condition.What the integration does not add
- is-kit does not wrap individual Compiler API functions.
- It does not depend on TypeScript at runtime or require a TypeScript peer dependency.
- It does not validate complete AST node shapes.
- It does not detect cycles or control AST traversal.
- It does not replace a clear one-off inline condition.
TypeScript 7 Compiler API compatibility
The current TypeScript 7 package does not expose a stable programmatic API equivalent to the TypeScript 6 package-root Compiler API. AST node types and isX predicates are available through the experimental typescript/unstable/ast entry point.
import * as ast from 'typescript/unstable/ast';import { and, refineKey } from 'is-kit';const isCallWithIdentifierExpression = and(ast.isCallExpression,refineKey('expression', ast.isIdentifier),);
The same is-kit composition patterns therefore work with TypeScript 7 AST predicates. Tooling that depends on the broader TypeScript 6 Compiler API or language-service surface may still need the official @typescript/typescript6 compatibility package during the transition.
typescript/unstable/ast is an experimental API surface and may change before TypeScript provides a stable programmatic API. This guidance will be re-evaluated when that AST API becomes stable, rather than tracking rolling nightly changes as documentation promises.This is a TypeScript 7 platform transition rather than an is-kit runtime limitation. is-kit itself has no TypeScript runtime or peer dependency.
Summary
- Broad TypeScript 7 AST nodes are not a discriminated union; keep using
isXfunctions to narrow them. - Use
refineKeyfor required child properties. - Use
refineDefinedKeywhen an optional child must exist. - Use
refineIndexfor one defined array element. - Keep local one-off checks inline when extraction adds no value.
- Treat Compiler API integration as an advanced application of generic property refinement, not the reason these helpers exist.
See thekey API referencefor the complete property contracts and thelogic API referencefor known-domain composition.