1

TypeScript Compiler API: preservando o narrowing de nós filhos em type guards reutilizáveis 🔧

Oi, oi! 👋

Eu sou @nyaomaru, engenheiro frontend explorando novas possibilidades com Jev 😸 (e também estou curioso sobre a "Decisions API" da OpenAI).

Recentemente, enquanto estudava a TypeScript Compiler API, comecei com uma pergunta bem específica:

É possível criar type guards reutilizáveis que preservem não apenas o tipo do nó AST, mas também o tipo refinado de uma propriedade filha?

No começo, achei que isso fosse um problema específico da Compiler API.

Depois reproduzi exatamente o mesmo padrão usando objetos TypeScript comuns.

E isso mudou a forma como eu estava olhando para o problema.

O ponto interessante não era realmente a AST.

Era property refinement.

Vamos ver! 👀

Image description


🌲 Um padrão muito comum na Compiler API

Imagine que temos um ts.Node genérico.

import * as ts from "typescript";

declare const node: ts.Node;

Queremos descobrir duas coisas:

  • É um CallExpression?
  • A propriedade expression é um Identifier?

Inline, isso é simples.

if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
  // node: ts.CallExpression
  // node.expression: ts.Identifier
  node.expression.text;
}

O TypeScript entende perfeitamente o control flow.

Não precisamos fazer nada especial.

E, sinceramente, se essa verificação aparecesse apenas uma vez, eu provavelmente deixaria exatamente assim.


🤔 E se quisermos reutilizar esse formato?

Agora imagine que o mesmo formato de AST apareça em vários lugares:

  • em um visitor
  • em filter
  • em find
  • em outra transformação
  • em outra regra de lint

Nesse ponto, dar um nome para essa verificação começa a fazer sentido.

Desde o TypeScript v5.5, funções simples frequentemente conseguem inferir um type predicate automaticamente.

Mas essa combinação de verificação do pai + filho é diferente.

const isCallWithIdentifierExpression = (node: ts.Node) =>
  ts.isCallExpression(node) && ts.isIdentifier(node.expression);
// inferred:
// (node: ts.Node) => boolean

Então, se quisermos que o predicate extraído preserve as duas informações, precisamos descrever explicitamente o tipo refinado.

const isCallWithIdentifierExpression = (
  node: ts.Node,
): node is ts.CallExpression & {
  expression: ts.Identifier;
} => ts.isCallExpression(node) && ts.isIdentifier(node.expression);

Isso funciona.

E então podemos reutilizá-lo:

declare const nodes: readonly ts.Node[];

const calls = nodes.filter(isCallWithIdentifierExpression);
// calls:
// Array<
//   ts.CallExpression & {
//     expression: ts.Identifier;
//   }
// >

Então qual é o problema?

Não existe realmente um problema em runtime.

A parte inconveniente é termos que escrever manualmente isto 👇

ts.CallExpression & {
  expression: ts.Identifier;
}

Mesmo já tendo feito exatamente essas verificações em runtime.

Seria bom conseguir compor as verificações e deixar o tipo acompanhar automaticamente. 😸


🧩 Refinando pai e filho juntos

Foi aí que acabei usando refineKey.

Com is-kit:

import * as ts from "typescript";
import { and, refineKey } from "is-kit";

const isCallWithIdentifierExpression = and(
  ts.isCallExpression,
  refineKey("expression", ts.isIdentifier),
);

Só isso.

Agora:

declare const node: ts.Node;

if (isCallWithIdentifierExpression(node)) {
  // node:
  // ts.CallExpression & {
  //   expression: ts.Identifier;
  // }
  node.expression.text;
}

A parte interessante é a relação entre as duas verificações.

ts.isCallExpression;

refina o objeto pai.

Depois:

refineKey("expression", ts.isIdentifier);

dentro da cadeia de narrowing composta, verifica uma propriedade do pai que já foi refinado por ts.isCallExpression e preserva o tipo refinado do filho.

Então a ideia é:

Verifique o filho uma vez em runtime e carregue essa mesma informação de volta para o tipo do pai.


😸 No fim, isso não era um problema de AST

Essa foi a parte que mais me surpreendeu durante a pesquisa.

No começo, achei que estivesse investigando uma limitação da TypeScript Compiler API.

Mas exatamente o mesmo formato aparece com objetos comuns.

Conceitualmente, o padrão é apenas:

Parent
  ↓
check property
  ↓
Parent & {
  property: RefinedChild
}

A Compiler API simplesmente é um ótimo stress test para isso, porque código que trabalha com AST contém esse padrão o tempo inteiro.

Por exemplo:

CallExpression
  → expression
  → Identifier

ou:

VariableDeclaration
  → initializer?
  → CallExpression

ou:

CallExpression
  → arguments[0]
  → StringLiteral

Por isso, não vejo refineKey como um helper específico para Compiler API.

A Compiler API é apenas um exemplo avançado de um problema de composição muito mais genérico.


🔗 Os guards da Compiler API já são altamente composáveis

Outra coisa que eu queria evitar era criar wrappers desnecessários para predicates que o próprio TypeScript já fornece.

A Compiler API já oferece excelentes guards:

ts.isStringLiteral;
ts.isIdentifier;
ts.isCallExpression;
ts.isClassDeclaration;

Devemos reutilizá-los.

Por exemplo:

import * as ts from "typescript";
import { or } from "is-kit";

const isStringLike = or(
  ts.isStringLiteral,
  ts.isNoSubstitutionTemplateLiteral,
);

declare const nodes: readonly ts.Node[];

const strings = nodes.filter(isStringLike);
// strings:
// (
//   | ts.StringLiteral
//   | ts.NoSubstitutionTemplateLiteral
// )[]

Não existe motivo para o is-kit criar funções próprias como:

isTsStringLiteral();
isTsIdentifier();
isTsCallExpression();

Isso simplesmente duplicaria a Compiler API.

A parte útil é a composição.


♻️ Reutilizando o mesmo guard em find e visitors

Isso começa a ficar mais útil quando o mesmo formato refinado aparece em vários contextos.

Por exemplo:

import * as ts from "typescript";
import { and, refineKey } from "is-kit";

const isIdentifierNamedJsxAttribute = and(
  ts.isJsxAttribute,
  refineKey("name", ts.isIdentifier),
);

Podemos usá-lo com find:

declare const attributes: readonly ts.JsxAttributeLike[];

const attribute = attributes.find(isIdentifierNamedJsxAttribute);
// attribute:
// (
//   ts.JsxAttribute & {
//     name: ts.Identifier;
//   }
// ) | undefined

E o mesmo guard funciona em um visitor:

function visit(node: ts.Node): void {
  if (isIdentifierNamedJsxAttribute(node)) {
    // node:
    // ts.JsxAttribute & {
    //   name: ts.Identifier;
    // }
    node.name.text;
  }

  ts.forEachChild(node, visit);
}

É aqui que extrair o guard começa a compensar.

A regra de runtime e o narrowing do TypeScript passam a viajar juntos.


🫥 Filhos opcionais têm um contrato diferente

Nós de AST contêm muitas propriedades opcionais.

Por exemplo, um VariableDeclaration pode ou não ter um initializer.

declaration.initializer;

Isso é um pouco diferente de refinar uma propriedade obrigatória.

Não queremos simplesmente:

Refine initializer.

Queremos:

Exija que initializer exista e depois refine seu valor.

Para esse caso, o is-kit possui refineDefinedKey.

import * as ts from "typescript";
import { refineDefinedKey } from "is-kit";

const hasCallInitializer = refineDefinedKey(
  "initializer",
  ts.isCallExpression,
);

Agora:

declare const declaration: ts.VariableDeclaration;

if (hasCallInitializer(declaration)) {
  // declaration.initializer: ts.CallExpression
  declaration.initializer.expression;
}

Dentro desse branch, initializer é simultaneamente:

  • presente
  • um ts.CallExpression

Um initializer ausente retorna false.

Um initializer explicitamente definido como undefined também retorna false.

Gosto de manter isso separado de refineKey, porque ausência é um comportamento de runtime, não apenas uma annotation do TypeScript.


📦 Arrays têm o mesmo problema

Arrays da AST introduzem outra pequena questão.

Imagine que queremos um CallExpression cujo primeiro argumento seja uma string literal.

Isto:

node.arguments[0];

parece simples, mas em runtime o array pode estar vazio.

Então precisamos provar duas coisas:

  • o índice 0 existe
  • o valor é um StringLiteral

Também podemos compor isso:

import * as ts from "typescript";
import { and, refineIndex, refineKey } from "is-kit";

const isCallWithStringFirstArgument = and(
  ts.isCallExpression,
  refineKey("arguments", refineIndex(0, ts.isStringLiteral)),
);

Depois:

declare const node: ts.Node;

if (isCallWithStringFirstArgument(node)) {
  // node: ts.CallExpression
  // node.arguments[0]: ts.StringLiteral
  node.arguments[0].text;
}

Agora sabemos que o índice 0 existe e contém um ts.StringLiteral.

Mais uma vez, isso não é realmente uma ideia específica de AST.

É apenas:

Refinar um local que foi verificado e preservar essa informação.


🪆 Verificações aninhadas continuam composáveis

Esses refinements também podem ser aninhados.

Imagine que queremos uma declaração semelhante a uma função cujo:

  • body existe
  • body é um block
  • o primeiro statement existe
  • o primeiro statement é um return statement

Podemos montar as partes separadamente:

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,
);

Depois:

declare const functionLike: ts.FunctionLikeDeclaration;

if (hasBodyStartingWithReturn(functionLike)) {
  // functionLike.body: ts.Block
  // functionLike.body.statements[0]: ts.ReturnStatement
  functionLike.body.statements[0].expression;
}

Cada etapa prova uma coisa.

Não existe uma path string como:

body.statements[0]

nem uma DSL especial para AST.

São apenas pequenos guards compostos.


🔒 Por que apenas uma key ou index concreto?

Existe uma limitação importante aqui.

Uma lookup bem-sucedida prova algo sobre um local concreto.

Se verificamos:

refineKey("expression", ...)

provamos alguma coisa sobre:

parent.expression;

Não provamos que todas as propriedades pertencentes a um domínio maior de keys passaram pelo mesmo teste.

É por isso que os helpers de refinement trabalham intencionalmente com uma única key ou index concreto.

Key unions amplas e outras afirmações envolvendo múltiplas posições tornariam muito mais fácil criar tipos que afirmam mais do que o runtime realmente verificou.

Prefiro que a API seja um pouco menos mágica a permitir que uma única lookup afirme mais do que realmente testou.


🧪 E o TypeScript 7?

Essa pesquisa ficou especialmente interessante porque o TypeScript v7 mudou o cenário da Compiler API.

Os exemplos desta seção foram verificados com TypeScript v7.0.2.

No TypeScript v7.0.2, os tipos e predicates da AST são expostos por:

typescript/unstable/ast

Então o mesmo estilo de composição pode ser usado ali:

import * as ast from "typescript/unstable/ast";
import { and, refineKey } from "is-kit";

const isCallWithIdentifierExpression = and(
  ast.isCallExpression,
  refineKey("expression", ast.isIdentifier),
);

Uma coisa que investiguei especificamente foi se o TypeScript v7 tornava esses checks isX desnecessários por causa de narrowing usando kind.

Para uma verdadeira discriminated union, o TypeScript consegue perfeitamente fazer narrowing a partir de um literal discriminant.

Mas o Node genérico atualmente exposto pela AST do TypeScript v7 não é esse tipo de closed discriminated union.

Então, quando trabalhamos com um AST node genérico, os predicates isX continuam sendo importantes.

Por exemplo:

import * as ast from "typescript/unstable/ast";

declare const node: ast.Node;

if (node.kind === ast.SyntaxKind.CallExpression) {
  // broad ast.Node does not automatically
  // expose CallExpression properties here
}

Essa diferença é importante.

Um tipo de AST customizado modelado como uma discriminated union pode se comportar de outra maneira.

Isso não significa que o Node genérico do TypeScript v7 atualmente se comporte da mesma forma.

Por que não transformar Node em uma closed union?

Depois que publiquei sobre isso, Jake Bailey deu uma resposta maravilhosamente concisa:

because it's slow 😞

Isso torna o trade-off muito mais fácil de entender.

Se Node fosse uma closed discriminated union contendo todos os tipos possíveis de AST nodes, kind poderia potencialmente oferecer narrowing mais forte e exhaustive checks.

Por exemplo, com uma closed union podemos usar o conhecido padrão com never:

switch (node.kind) {
  // handle every known kind...

  default: {
    const exhaustive: never = node;
  }
}

Quando uma nova variante fosse adicionada, esse check com never poderia falhar em compile time e nos avisar que o tratamento deixou de ser exaustivo.

Mas esse modelo de tipos mais forte não vem de graça.

O custo é performance durante o type checking: uma closed union muito grande dá mais trabalho para o checker.

Então a forma mais ampla de ast.Node não é simplesmente uma feature de narrowing ausente.

Existe um trade-off real:

maior exhaustiveness em compile time vs. performance do type checker

Isso também ajuda a explicar por que predicates explícitos como ast.isCallExpression() continuam tendo um papel importante.

Há mais um detalhe específico do TypeScript 7 que vale lembrar.

A parte unstable de:

typescript/unstable/ast

também é importante.

Eu não criaria promessas de documentação em torno de uma API que ainda está evoluindo.

O padrão de composição é genérico.

A integração específica com TypeScript v7 pode evoluir junto com o próprio TypeScript.


✋ Você provavelmente não precisa disso para cada verificação de AST

Isso também é importante.

Se eu tiver apenas uma condição local:

if (ts.isReturnStatement(node) && node.expression) {
  // node: ts.ReturnStatement
  // node.expression: ts.Expression
  visit(node.expression);
}

eu deixaria assim, inline.

Sério.

Transformar isso em:

const isReturnWithExpression = ...

só porque podemos não torna o código automaticamente melhor.

Para mim, a divisão útil é:

SituaçãoMelhor opção
Um branch localChecks nativos ts.isX
Formato de AST repetidoGuard reutilizável com nome
Projeto já usa is-kitrefineKey, refineDefinedKey, refineIndex

O objetivo não é:

Substituir toda condição ts.isX por is-kit.

O objetivo é:

Quando um fato de runtime se transforma em vocabulário reutilizável, mantenha o narrowing reutilizável também.


🚫 O que isso não tenta fazer

is-kit não tenta se transformar em um framework para Compiler API.

Ele não:

  • cria wrappers para funções individuais da Compiler API
  • valida o formato completo de AST nodes
  • controla AST traversal
  • detecta ciclos na AST
  • adiciona TypeScript como runtime dependency
  • exige TypeScript como peer dependency
  • substitui checks inline claros usados apenas uma vez

A Compiler API simplesmente é um exemplo real e exigente de generic property refinement.

Essa é a fronteira que quero manter.


🎯 A parte importante

Comecei essa pesquisa pensando:

Talvez a TypeScript Compiler API precise de algum tratamento especial.

O que encontrei foi algo mais geral.

O problema recorrente era:

narrow parent
    ↓
check child
    ↓
preserve both facts
    ↓
reuse the predicate

Isso é útil para AST nodes, mas na verdade não é sobre AST nodes.

Então meu modelo mental atual é:

  • use native type guards para representar o conhecimento real de runtime
  • mantenha condições usadas uma única vez inline
  • componha um named guard quando o mesmo formato verificado se tornar reutilizável
  • preserve o refinement do filho no tipo do pai em vez de reescrever intersection types manualmente

Para a Compiler API, isso pode ficar assim:

const isCallWithIdentifierExpression = and(
  ts.isCallExpression,
  refineKey("expression", ts.isIdentifier),
);

Pequenos checks em runtime.

Pequenas peças reutilizáveis.

E o TypeScript preserva exatamente as informações que realmente verificamos. 😸

Também escrevi um guia mais detalhado com exemplos envolvendo propriedades obrigatórias, propriedades opcionais, array indices, AST shapes aninhados e TypeScript 7:

Advanced Property Refinement with the TypeScript Compiler API

https://is-kit.dev/guides/typescript-compiler-api

E, se quiser explorar o is-kit:

https://github.com/nyaomaru/is-kit

Se parecer útil para você, uma ⭐ no GitHub é sempre muito bem-vinda!

Obrigado por ler! 🙌

Carregando publicação patrocinada...