Skip to content

TypeScript AST Mutations

Boris Penkov edited this page Aug 26, 2024 · 2 revisions

Table of contents

  1. Overview
  2. TypeScriptNodeFactory
    1. API And Usage
    2. Initialization
    3. Methods for creating nodes
  3. TypeScriptAstTransformer
    1. API And Usage
    2. Initialization
    3. Visitor Conditions
    4. Methods that mutate the AST
    5. Updating the source file
  4. Limitations
  5. FormattingService

This document outlines the ways a TypeScript file can be mutated using API that exists in the cli-core repo. The logic is divided into a factory that can create new TypeScript nodes, an AST transformer which mutates existing nodes in the file, and a formatting service which applies formatting to the final code based on FormatSettings.

It can currently create the following types of nodes:

  • object literal expressions
  • array literal expressions
  • import declarations
  • dynamic imports
  • call expressions
  • property assignments with an arrow function as value that has a call expression in its body

When initializing the TypeScriptNodeFactory, you can provide FormatSettings which will be used to style the newly-created nodes.

const formatSettings: FormatSettings = { singleQuotes: true };
const factory = new TypeScriptNodeFactory(formatSettings);

The TypeScriptNodeFactory contains methods that can be used to create new nodes. They are wrappers around methods of the same/similar names in the ts.factory and are exposed for ease of use.

Method Description
createObjectLiteralExpression Creates a ts.ObjectLiteralExpression with a set of key-value pair properties. It has an optional transform delegate that can be used to mutate the object's properties' values to a ts.LiteralExpression, by default it will transform them to a ts.StringLiteral. The newly-created object literal can be on single or multiple lines.
createArrayLiteralExpression Creates a ts.ArrayLiteralExpression with the provided elements. It supports both primitive and complex elements. The newly-created array literal can be on single or multiple lines.
createCallExpression Creates a ts.CallExpression for a given identifier that calls a method.
createArrowFunctionWithCallExpression Creates a property assignment with a zero arity arrow function as the value, which has a call expression in its body. Takes the form memberName: () => callExpressionName(callExpressionArgs).
createDynamicImport Creates an arrow function with no arity that returns a dynamic import. Takes the form () => import(path).then(m => m.prop).
createImportDeclaration Creates a node for a ts.ImportDeclaration that can be a side effects import, a default import or an import with named bindings.

The types of imports that the createImportDeclaration method currently supports are:

  • Side effects Import - import "my-module;"
  • Default Import - import X from "my-module";
  • Imports With Named Bindings - import { X, Y... } from "my-module";

Examples

An example of createCallExpression could be:

const typeArg = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
const arg = ts.factory.createNumericLiteral("5");
const callExpression = factory.createCallExpression("x", "myGenericFunction", [typeArg], [arg]);

The callExpression constant will contain a ts.CallExpression node which if printed will look something like this:

x.myGenericFunction<number>(5);

An example of createArrayLiteralExpression could be:

const newArrayLiteral = factory.createArrayLiteralExpression([
  {
    name: 'key1',
    value: ts.factory.createStringLiteral('new-value'),
  },
  {
    name: 'key2',
    value: ts.factory.createNumericLiteral('5'),
  },
]);
// If printed the above node will look like this - [{ key1: 'new-value' }, { key2: 5 }]

An example of createObjectLiteralExpression could be:

const newObjectLiteral = factory.createObjectLiteralExpression(
  [{ name: 'key1', value: ts.factory.createStringLiteral('new-value') }]
);
// If printed, the above node will look like this - { key1: 'new-value }

These two methods can be used together:

const newObjectLiteral = factory.createObjectLiteralExpression(
  [{ name: 'key1', value: ts.factory.createStringLiteral('new-value') }]
);

const newArrayLiteral = factory.createArrayLiteralExpression([newObjectLiteral]);
// If printed the `newArrayLiteral` node will look like this - [{ key1: 'new-value' }]

Important

Creating new nodes with the TypeScriptNodeFactory or the ts.factory and inserting them in the AST with a transformation should be handled with great care. The reason being that the AST is only built during the parsing of the source code and any nodes added afterwards can cause unwanted behavior when some of their members are accessed - for example members such as node.getText or node.parent can cause throws or undefined returns. This is because node.getText relies on the position of the node in the AST to get its source code representation, with a dynamically added node, its position is not defined and the method will throw. For node.parent, the newly-created node is not aware of its parent and accessing that will return undefined. The parent is also only resolved during parsing of the source code and is otherwise readonly. However, a parent will be aware of its children, even if they are dynamically added nodes.

The TypeScriptAstTransformer utility is designed to be able to manipulate a TypeScript AST structure and apply different changes to it. It is platform agnostic and is only concerned with the modification of a ts.SourceFile. As such, it expects a source file as input and exposes multiple utility methods that stage changes for the source file. The changes are later applied consecutively during finalization.

It can modify different TypeScript nodes as follows:

  • modify existing members of object literals
  • add new members to object literals
  • prepend/append members to array literals
    • it supports an optional anchor element that it can prepend/append elements around
  • add identifiers to existing import declarations
    • will detect if the newly-added import declaration's identifier(s) already exist
  • detect collisions between existing import declarations
  • create a call expression of the form x.call<T>(args)
    • where the type argument and the method arguments are optional
  • modify arguments in a method call expression
  • look up a node's ancestor and check it against a condition
  • look up a ts.PropertyAssignment in an object literal
  • look up an identifier/element in an array literal

To initialize the AST Transformer you need to provide a ts.SourceFile. Optional parameters are ts.PrinterOptions, ts.CompilerOptions and FormatSettings.

const fileName = './mySourceFile.ts';
const fileContent = 'const a = 5;'
const sourceFile: ts.SourceFile = ts.createSourceFile(fileName, fileContent, ts.ScriptTarget.Latest);
const astTransformer = new TypeScriptAstTransformer(sourceFile);
  • The ts.PrinterOptions are used by the transformer's ts.Printer and allow for the resulting text to be modified by some parameters. For example, it can remove any comments, set the type of the new line character, etc.
  • The ts.CompilerOptions are used while applying the stored transformations and host a variety of options that can be used to apply to the resulting source.
  • The FormatSettings are used in the TypeScriptFormattingService to apply different styles to the resulting code, like the number of spaces/tabs, if strings should be formatted with single or double quotes, etc.

Visitor Conditions

A visitorCondition function is a predicate that is used by some of the methods in the AST transformer to narrow down to a specific node that is to be modified.

If we have the following code:

const myOtherArr = [];
const myArr = [1];

And we want to add a new member to the array literal initializer of the const myArr, we can do it like so:

const visitorCondition = (node: ts.ArrayLiteralExpression) =>
  node.elements.some((e) => ts.isNumericLiteral(node) && node.text === "1");

astTransformer.requestNewMembersInArrayLiteral(visitorCondition, [newObjectLiteral]);

In this simplified example, the visitorCondition will resolve to true for the array that contains an element that is the numeric literal of 1. For the method requestNewMembersInArrayLiteral, the provided argument in the visitorCondition will always be a ts.ArrayLiteralExpression, as this is the type the method is interested in modifying.

Note

All methods that request specific changes will at least narrow down to a node of the respective concrete type and provide it as a typed argument in the visitorCondition.

As shown in the Visitor Conditions section, the transformer contains methods that can be used to request changes in the AST. All of them require a predicate that is used when drilling down to the appropriate node.

Method Description
requestNewMemberInObjectLiteral Creates a request for an update in the AST that will add a new member (ts.PropertyAssignment) in an object literal expression.
requestJsxMemberInObjectLiteral Similar to requestNewMemberInObjectLiteral with the only difference being that the value of the created member will be a ts.JsxSelfClosingElement.
requestUpdateForObjectLiteralMember Creates a request for an update in the AST that will change the value of a member in an object literal.
requestNewMembersInArrayLiteral Creates a request for an update in the AST that will add n members in a particular ts.ArrayLiteralExpression. It supports an optional anchorElement that can be used to prepend/append the new nodes around a given target.
requestNewImportDeclaration Creates a request for an update in the AST that will add a new import declaration of the forms outlined in createImportDeclaration.
requestNewArgumentInMethodCallExpression Creates a request which will add a new argument to a method call expression.

Examples

An example usage of createObjectLiteralExpression alongside requestNewMembersInArrayLiteral could be:

const newObjectLiteral = factory.createObjectLiteralExpression([
  { name: "path", value: ts.factory.createStringLiteral("some-new-path") },
  { name: "component", value: ts.factory.createIdentifier("MyComponent") },
]);

// the condition that will be used when traversing the AST to narrow down to the node that we want to modify
const condition = (node: ts.ArrayLiteralExpression) =>
  node.elements.some(
    (e) =>
      ts.isObjectLiteralExpression(e) &&
      e.properties.some(
        (p) =>
          ts.isPropertyAssignment(p) &&
          ts.isIdentifier(p.name) &&
          p.name.text === "path"
      )
  );

astTransformer.requestNewMembersInArrayLiteral(condition, [newObjectLiteral]);

This will create a new object ({ path: "some-new-path", component: MyComponent }) and add it to an array literal that has an object literal member with property with a name path. In this example the multiline parameter is not provided to either factory.createObjectLiteralExpression nor astTransformer.requestNewMembersInArrayLiteral, so the result will be an object literal on a single line, added at the end of the targeted array literal, again on the same line.

So this:

const routes: Route[] = [{ path: "some-path", component: SomeComponent }];

Will become this:

const routes: Route[] = [{ path: "some-path", component: SomeComponent }, { path: "some-new-path", component: MyComponent }];

Note

Keep in mind that this is a very crude and simplified example as in reality additional checks will have to be done to make sure that the node that is being added goes precisely where it is supposed to.

The requestNewMembersInArrayLiteral also has an anchorElement parameter that can be used to put the element before or after a specific element in the array literal. So modifying the above example to include an anchor and also to apply multiline for a more beautiful result can be done like this:

const newObjectLiteral = factory.createObjectLiteralExpression(
  [
    { name: "path", value: ts.factory.createStringLiteral("some-new-path") },
    { name: "component", value: ts.factory.createIdentifier("MyComponent") }
  ],
  true // multiline
);
const anchorElement: PropertyAssignment = {
  name: 'path',
  value: ts.factory.createStringLiteral('anchor')
};

astTransformer.requestNewMembersInArrayLiteral(
  condition,
  [newObjectLiteral],
  true, // prepend
  anchorElement,
  true // multiline
);

So, if we have an array literal like this:

const routes: Route[] = [
  { path: "anchor", component: SomeComponent }
];

After the transformation, it will become like this:

const routes: Route[] = [
  {
    path: 'some-new-path',
    value: MyComponent
  },
  { path: 'anchor', component: SomeComponent }
];

One thing to mention here is that our initial array is formatted on multiple lines as well. This is the reason why the newly-created object literals are added in C# style, with the opening brace on a new line. If the array was instead on a single line, like in the initial example:

const routes: Route[] = [{ path: "some-path", component: SomeComponent }];

Then multiline will only be applied for the new nodes, meaning the resulting code will look like this:

const routes: Route[] = [{
    path: 'some-new-path',
    value: MyComponent
  }, { path: 'anchor', component: SomeComponent }
];

Note

Also, keep in mind that formatting will be applied only if FormatSettings have been provided during the transformer's instantiation. And that the FormattingService will attempt to access the FS to read editor and formatting configs and to write the final output.

The TypeScriptAstTransformer will only store the requested changes and will not apply any of them until either finalize or applyChanges is called.

Method Description
applyChanges Applies the aggregated changes to the ts.SourceFile and returns the resulting AST, does not modify the original one.
finalize Calls applyChanges internally and then prints the resulting source code, a formatter can be used to make the code prettier.

Note

Calling either of these will clear the transformer's cache of requested changes.

Regarding any of the exposed utilities, the transformer will not attempt to fix potentially broken code as it is only concerned with the modification of the AST and not whether or not the resulting code is actually runnable.

The TypeScriptFormattingService is a utility used by the transformer after applying the changes to the AST and finalizing the source. This service will read a project's .editorconfig and attempt to format the source code by using the ts.LanguageService's formatting capabilities. Then it will save the file onto the file system by utilizing the App.container utility, which depending on the context can communicate directly with the physical file system or a virtual one.

API

Method Description
applyFormatting Apply formatting to a source file.

As P1, it will also support a formatting utility such as biome, prettier, etc.