TypeScript Compiler API: Parsear, Transformar e Gerar Código na Prática Já leu

Introdução à TypeScript Compiler API A TypeScript Compiler API é um conjunto de interfaces e funções que permitem interagir programaticamente com o compilador TypeScript. Diferentemente de usar o via linha de comando, a API oferece controle fino sobre como o código é parseado, transformado e gerado. Isso abre possibilidades para criar ferramentas de análise estática, geradores de código, refatoradores e linters customizados. A maioria dos desenvolvedores nunca trabalha diretamente com essa API, mas ferramentas populares como ESLint, Prettier, NestJS e até IDEs modernas a utilizam internamente. Entender como funciona permite que você crie soluções sofisticadas que manipulam código TypeScript/JavaScript como dados estruturados, abrindo um mundo de automação e análise que seria impossível com abordagens baseadas em regex ou parsing manual. Conceitos Fundamentais Abstract Syntax Tree (AST) Quando o compilador TypeScript processa código, ele não o vê como texto bruto. Primeiro, transforma-o em uma árvore de sintaxe abstrata (AST), onde cada elemento do código — variáveis, funções, tipos, expressões — torna-se

Introdução à TypeScript Compiler API

A TypeScript Compiler API é um conjunto de interfaces e funções que permitem interagir programaticamente com o compilador TypeScript. Diferentemente de usar o tsc via linha de comando, a API oferece controle fino sobre como o código é parseado, transformado e gerado. Isso abre possibilidades para criar ferramentas de análise estática, geradores de código, refatoradores e linters customizados.

A maioria dos desenvolvedores nunca trabalha diretamente com essa API, mas ferramentas populares como ESLint, Prettier, NestJS e até IDEs modernas a utilizam internamente. Entender como funciona permite que você crie soluções sofisticadas que manipulam código TypeScript/JavaScript como dados estruturados, abrindo um mundo de automação e análise que seria impossível com abordagens baseadas em regex ou parsing manual.

Conceitos Fundamentais

Abstract Syntax Tree (AST)

Quando o compilador TypeScript processa código, ele não o vê como texto bruto. Primeiro, transforma-o em uma árvore de sintaxe abstrata (AST), onde cada elemento do código — variáveis, funções, tipos, expressões — torna-se um nó estruturado. Essa representação hierárquica é a base para tudo que faremos com a API.

Cada nó na AST tem um tipo específico (como FunctionDeclaration, VariableStatement, InterfaceDeclaration) e propriedades que descrevem seus detalhes. Compreender essa estrutura é essencial, pois você passará a maior parte do tempo navegando e inspecionando esses nós.

import * as ts from 'typescript';

const code = `
function greet(name: string): void {
  console.log("Hello, " + name);
}
`;

// Criar um arquivo virtual de origem
const sourceFile = ts.createSourceFile(
  'example.ts',
  code,
  ts.ScriptTarget.Latest,
  true
);

// Função recursiva para inspecionar os nós
function visitNode(node: ts.Node, depth: number = 0) {
  const indent = ' '.repeat(depth * 2);
  console.log(`${indent}${ts.SyntaxKind[node.kind]}`);

  ts.forEachChild(node, child => visitNode(child, depth + 1));
}

visitNode(sourceFile);

Este exemplo imprime a hierarquia da AST. Você verá SourceFile, depois FunctionDeclaration, depois seus filhos (Identifier, Parameter, Block, etc.). Essa estrutura é determinística e previsível.

Visitantes e Transformadores

A forma mais elegante de trabalhar com ASTs é através do padrão Visitor. TypeScript fornece funções como forEachChild, visit e visitEachChild que permitem percorrer a árvore. Além disso, a API oferece transformadores (transformers) que permitem modificar a AST de forma imutável, gerando uma nova árvore transformada.

Transformadores são especialmente poderosos porque mantêm a integridade da árvore — não modificam nós diretamente, mas retornam versões novas com as mudanças desejadas. Isso é importante para manter consistência e rastreabilidade.

Parseando Código

Parseamento Básico

O primeiro passo é converter código em texto para uma AST. A função createSourceFile é sua porta de entrada. Ela recebe o nome do arquivo, o conteúdo e configurações de destino.

import * as ts from 'typescript';

function parseTypeScriptCode(filePath: string, code: string): ts.SourceFile {
  return ts.createSourceFile(
    filePath,
    code,
    ts.ScriptTarget.ES2020,
    true, // setParentNodes = true para ter acesso aos pais
  );
}

// Exemplo de uso
const myCode = `
interface User {
  id: number;
  name: string;
  email?: string;
}

const user: User = { id: 1, name: "Alice" };
`;

const ast = parseTypeScriptCode('models.ts', myCode);
console.log(`Arquivo parseado com sucesso. Kind: ${ast.kind}`);

O parâmetro setParentNodes é crucial — quando ativado, cada nó mantém uma referência ao seu pai na árvore, permitindo navegação bidirecional.

Inspecionando Nós Específicos

Depois de parsear, geralmente queremos encontrar nós de tipos específicos. Uma estratégia comum é criar um visitante que coleta informações.

import * as ts from 'typescript';

interface FunctionInfo {
  name: string;
  parameters: string[];
  returnType?: string;
}

class FunctionCollector {
  functions: FunctionInfo[] = [];

  visit(node: ts.Node): void {
    if (ts.isFunctionDeclaration(node)) {
      const name = node.name?.getText() || 'anonymous';
      const parameters = node.parameters.map(p => p.name?.getText() || 'param');
      const returnType = node.type?.getText();

      this.functions.push({ name, parameters, returnType });
    }

    ts.forEachChild(node, child => this.visit(child));
  }

  collect(sourceFile: ts.SourceFile): FunctionInfo[] {
    this.visit(sourceFile);
    return this.functions;
  }
}

// Uso
const code = `
function add(a: number, b: number): number {
  return a + b;
}

async function fetchUser(id: string): Promise<User> {
  // ...
}
`;

const sourceFile = ts.createSourceFile('math.ts', code, ts.ScriptTarget.Latest, true);
const collector = new FunctionCollector();
const functions = collector.collect(sourceFile);

console.log(JSON.stringify(functions, null, 2));
// Output:
// [
//   { name: "add", parameters: ["a", "b"], returnType: "number" },
//   { name: "fetchUser", parameters: ["id"], returnType: "Promise<User>" }
// ]

Este padrão é a base para análise estática. Você define predicados (isFunctionDeclaration, isInterfaceDeclaration, etc.) para filtrar nós e extrair informações.

Transformando Código

Transformadores Básicos

A transformação de AST é onde a magia acontece. Em vez de trabalhar com strings, você modifica a estrutura do código programaticamente. A chave é usar ts.visitEachChild combinado com ts.visitNode, que preserva a estrutura enquanto permite substituições.

import * as ts from 'typescript';

// Transformador que adiciona um prefixo a todas as variáveis
const createPrefixTransformer = (prefix: string) => {
  return (context: ts.TransformationContext) => {
    const visit: ts.Visitor = (node: ts.Node): ts.Node => {
      if (ts.isVariableDeclaration(node)) {
        const newName = ts.factory.createIdentifier(prefix + node.name.getText());
        return ts.factory.updateVariableDeclaration(
          node,
          newName,
          node.exclamationToken,
          node.type,
          node.initializer,
        );
      }

      return ts.visitEachChild(node, visit, context);
    };

    return (sourceFile: ts.SourceFile) => ts.visit(sourceFile, visit);
  };
};

// Aplicar transformador
const code = `
let count: number = 0;
const message: string = "hello";
var flag = true;
`;

const sourceFile = ts.createSourceFile('vars.ts', code, ts.ScriptTarget.Latest, true);
const result = ts.transform(sourceFile, [createPrefixTransformer('$')]);
const transformed = result.transformed[0];

// Verificar resultado
function printAST(node: ts.Node): string {
  if (ts.isVariableDeclaration(node)) {
    return node.name.getText();
  }
  return '';
}

ts.forEachChild(transformed, child => {
  ts.forEachChild(child, decl => {
    if (ts.isVariableDeclaration(decl)) {
      console.log(`Nome da variável: ${decl.name.getText()}`);
    }
  });
});

Note que você não modifica nós diretamente. Em vez disso, usa ts.factory para criar novos nós com as alterações desejadas. O ts.visitEachChild navega pela árvore recursivamente, permitindo que você aplique transformações em múltiplos nós.

Exemplo Prático: Adicionar Logs Automáticos

Um caso de uso real é adicionar statements de log a funções automaticamente. Isso é útil para debugging ou auditoria.

import * as ts from 'typescript';

const createLogTransformer = () => {
  return (context: ts.TransformationContext) => {
    const visit: ts.Visitor = (node: ts.Node): ts.Node => {
      if (ts.isFunctionDeclaration(node) && node.body) {
        const functionName = node.name?.getText() || 'anonymous';

        // Criar statement de log
        const logStatement = ts.factory.createExpressionStatement(
          ts.factory.createCallExpression(
            ts.factory.createPropertyAccessExpression(
              ts.factory.createIdentifier('console'),
              'log',
            ),
            undefined,
            [ts.factory.createStringLiteral(`Entering function: ${functionName}`)],
          ),
        );

        // Adicionar log como primeira statement do corpo
        const newBody = ts.factory.updateBlock(
          node.body,
          [logStatement, ...node.body.statements],
        );

        return ts.factory.updateFunctionDeclaration(
          node,
          node.modifiers,
          node.asteriskToken,
          node.name,
          node.typeParameters,
          node.parameters,
          node.type,
          newBody,
        );
      }

      return ts.visitEachChild(node, visit, context);
    };

    return (sourceFile: ts.SourceFile) => ts.visit(sourceFile, visit);
  };
};

// Uso
const code = `
function calculate(x: number): number {
  return x * 2;
}

function greet(name: string): void {
  console.log("Hi, " + name);
}
`;

const sourceFile = ts.createSourceFile('app.ts', code, ts.ScriptTarget.Latest, true);
const result = ts.transform(sourceFile, [createLogTransformer()]);
const transformed = result.transformed[0];

// Gerar código (próxima seção)
const printer = ts.createPrinter();
const output = printer.printFile(transformed);
console.log(output);

A saída será código com console.log adicionado automaticamente no início de cada função. Essa transformação é não-destrutiva — o original permanece intacto, e você obtém uma nova AST transformada.

Gerando Código

Imprimindo a AST Transformada

Depois de transformar a AST, você precisa convertê-la de volta para texto legível. A classe Printer do TypeScript faz exatamente isso, e é extremamente simples.

import * as ts from 'typescript';

// Assumindo que você tem uma sourceFile transformada
const sourceFile = ts.createSourceFile(
  'example.ts',
  'const x: number = 42;',
  ts.ScriptTarget.Latest,
  true,
);

const printer = ts.createPrinter();
const code = printer.printFile(sourceFile);
console.log(code);
// Output: const x: number = 42;

O Printer é responsável pela formatação final. Ele respeita a configuração do compilador e gera código válido de TypeScript.

Construindo Código do Zero

Às vezes, você não está transformando código existente, mas gerando-o completamente do zero. Use ts.factory para criar uma AST inteira.

import * as ts from 'typescript';

// Gerar interface automaticamente
function generateInterface(name: string, fields: Record<string, string>): ts.InterfaceDeclaration {
  const members = Object.entries(fields).map(([fieldName, fieldType]) =>
    ts.factory.createPropertySignature(
      undefined,
      fieldName,
      undefined,
      ts.factory.createTypeReferenceNode(fieldType),
    ),
  );

  return ts.factory.createInterfaceDeclaration(
    undefined,
    [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
    name,
    undefined,
    undefined,
    members,
  );
}

// Gerar uma source file com a interface
function createSourceFileFromInterface(
  interfaceName: string,
  fields: Record<string, string>,
): ts.SourceFile {
  const interfaceDecl = generateInterface(interfaceName, fields);

  // Criar um arquivo virtual vazio e adicionar a interface
  const sourceFile = ts.createSourceFile(
    `${interfaceName}.ts`,
    '',
    ts.ScriptTarget.Latest,
    true,
    ts.ScriptKind.TS,
  );

  // Usar o factory para criar um novo arquivo com o statement
  const newSourceFile = ts.factory.updateSourceFile(sourceFile, [interfaceDecl]);
  return newSourceFile;
}

// Uso
const generatedFile = createSourceFileFromInterface('Product', {
  id: 'number',
  name: 'string',
  price: 'number',
  inStock: 'boolean',
});

const printer = ts.createPrinter();
const output = printer.printFile(generatedFile);
console.log(output);
// Output:
// export interface Product {
//     id: number;
//     name: string;
//     price: number;
//     inStock: boolean;
// }

Este padrão é usado por geradores de código como OpenAPI generators, ORMs e frameworks que criam tipos a partir de metadados externos.

Salvando em Arquivo

Para completar o ciclo, você geralmente salva o código gerado em disco.

import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';

function saveGeneratedCode(
  dirPath: string,
  fileName: string,
  sourceFile: ts.SourceFile,
): void {
  const fullPath = path.join(dirPath, fileName);

  // Criar diretório se não existir
  if (!fs.existsSync(dirPath)) {
    fs.mkdirSync(dirPath, { recursive: true });
  }

  const printer = ts.createPrinter();
  const code = printer.printFile(sourceFile);

  fs.writeFileSync(fullPath, code, 'utf-8');
  console.log(`✓ Código gerado em: ${fullPath}`);
}

// Exemplo de uso
const generated = createSourceFileFromInterface('User', {
  id: 'string',
  email: 'string',
  createdAt: 'Date',
});

saveGeneratedCode('./src/generated', 'User.ts', generated);

Caso de Uso Completo: Gerador de DTOs

Para consolidar tudo que foi aprendido, vamos criar um gerador de DTOs (Data Transfer Objects) que lê interfaces e cria classes correspondentes com validação automática.

import * as ts from 'typescript';
import * as fs from 'fs';

interface PropertyDef {
  name: string;
  type: string;
  optional: boolean;
}

class DTOGenerator {
  private sourceFile: ts.SourceFile;

  constructor(filePath: string, code: string) {
    this.sourceFile = ts.createSourceFile(
      filePath,
      code,
      ts.ScriptTarget.Latest,
      true,
    );
  }

  // Extrair interfaces do código
  extractInterfaces(): Map<string, PropertyDef[]> {
    const interfaces = new Map<string, PropertyDef[]>();

    const visit = (node: ts.Node) => {
      if (ts.isInterfaceDeclaration(node)) {
        const interfaceName = node.name.getText();
        const properties: PropertyDef[] = [];

        node.members.forEach(member => {
          if (ts.isPropertySignature(member)) {
            const propName = member.name?.getText() || '';
            const propType = member.type?.getText() || 'any';
            const isOptional = member.questionToken !== undefined;

            properties.push({
              name: propName,
              type: propType,
              optional: isOptional,
            });
          }
        });

        interfaces.set(interfaceName, properties);
      }

      ts.forEachChild(node, visit);
    };

    visit(this.sourceFile);
    return interfaces;
  }

  // Gerar classe DTO para uma interface
  generateDTOClass(interfaceName: string, properties: PropertyDef[]): ts.ClassDeclaration {
    // Criar propriedades da classe
    const classProperties = properties.map(prop =>
      ts.factory.createPropertyDeclaration(
        [ts.factory.createModifier(ts.SyntaxKind.PublicKeyword)],
        prop.name,
        prop.optional ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
        ts.factory.createTypeReferenceNode(prop.type),
        undefined,
      ),
    );

    // Criar construtor
    const constructorParameters = properties.map(prop =>
      ts.factory.createParameterDeclaration(
        undefined,
        undefined,
        prop.name,
        prop.optional ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined,
        ts.factory.createTypeReferenceNode(prop.type),
      ),
    );

    const constructorBody = ts.factory.createBlock(
      properties.map(prop =>
        ts.factory.createExpressionStatement(
          ts.factory.createBinaryExpression(
            ts.factory.createPropertyAccessExpression(
              ts.factory.createThis(),
              prop.name,
            ),
            ts.SyntaxKind.EqualsToken,
            ts.factory.createIdentifier(prop.name),
          ),
        ),
      ),
      true,
    );

    const constructor = ts.factory.createConstructorDeclaration(
      undefined,
      constructorParameters,
      constructorBody,
    );

    // Montar a classe
    const dtoClassName = `${interfaceName}DTO`;
    return ts.factory.createClassDeclaration(
      undefined,
      [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
      dtoClassName,
      undefined,
      undefined,
      [constructor, ...classProperties],
    );
  }

  // Gerar múltiplas classes e combinar em um arquivo
  generateDTOFile(): ts.SourceFile {
    const interfaces = this.extractInterfaces();
    const statements: ts.Statement[] = [];

    interfaces.forEach((properties, interfaceName) => {
      const dtoClass = this.generateDTOClass(interfaceName, properties);
      statements.push(dtoClass);
    });

    const sourceFile = ts.createSourceFile(
      'generated-dtos.ts',
      '',
      ts.ScriptTarget.Latest,
      true,
      ts.ScriptKind.TS,
    );

    return ts.factory.updateSourceFile(sourceFile, statements);
  }

  print(): string {
    const generatedFile = this.generateDTOFile();
    const printer = ts.createPrinter();
    return printer.printFile(generatedFile);
  }
}

// Uso prático
const sourceCode = `
interface User {
  id: string;
  email: string;
  name: string;
  phone?: string;
}

interface Product {
  sku: string;
  title: string;
  price: number;
  description?: string;
}
`;

const generator = new DTOGenerator('models.ts', sourceCode);
const output = generator.print();
console.log(output);

// Salvar em arquivo
fs.writeFileSync('generated-dtos.ts', output, 'utf-8');

Este exemplo demonstra o ciclo completo: parseamento de interfaces existentes, extração de informações, geração de novas classes e impressão de código válido. Esse padrão é usado em bibliotecas reais de geração de código.

Conclusão

Dominando a TypeScript Compiler API, você agora compreende como compiladores modernos funcionam internamente — não como caixas pretas, mas como sistemas estruturados que transformam texto em ASTs, manipulam essas árvores e regeneram código válido. Isso abre portas para criar ferramentas sofisticadas de análise estática, geradores de código e refatoradores que operam no nível semântico, não textual.

Os três pilares que consolidamos foram: primeiro, parseamento — converter código em estruturas de dados navegáveis; segundo, transformação — modificar ASTs de forma funcional e imutável usando o padrão Visitor; terceiro, geração — converter árvores de volta para código legível. Compreender esses três pilares permite que você construa qualquer ferramenta que necessite manipular TypeScript/JavaScript programaticamente.

A chave para evitar frustrações é lembrar que você não trabalha com strings, mas com objetos fortemente tipados. Sempre use as funções isFunctionDeclaration, isVariableStatement, etc., para validar tipos antes de acessar propriedades, e use ts.factory para criar novos nós em vez de tentar modificar os existentes.

Referências


Artigos relacionados