25.7 Building a Simple Code Generator
Right, so you want to build a code generator. Not just any code generator, but one that understands the structure of the code it’s manipulating. You’re not just concatenating strings like a barbarian; you’re a sculptor, and the TypeScript Compiler API is your chisel. It’s the difference between sending a mass email and writing a personal letter. The former might get the job done, but the latter is correct, robust, and doesn’t accidentally call the recipient by the wrong name.
We’re going to build a simple generator that creates a function and its corresponding interface. Why? Because it’s the perfect cocktail of common and slightly non-trivial. We’ll use the ts factory functions, which are your best friends for creating new AST nodes. Forget about manually constructing those deeply nested object literals with a hundred properties; the factory does the heavy lifting for you.
First, the setup. You’ll need to create a project, a SourceFile to act as your canvas. We’re not parsing existing code here; we’re creating something from nothing.
import ts from "typescript";
// Create a new source file to hold our generated code.
// The first argument is the filename, the second is the starting text of the file (empty),
// and the third is the language version/script target.
const sourceFile = ts.createSourceFile(
"generated.ts",
"",
ts.ScriptTarget.Latest,
false, // setParentNodes: not needed for generation
ts.ScriptKind.TS
);
// We'll need a printer to turn our beautiful AST back into text we can save.
const printer = ts.createPrinter({
newLine: ts.NewLineKind.LineFeed,
});
The Factory Functions Are Your Workforce
The ts.factory object contains a function for nearly every type of AST node. You don’t new up a node; you call ts.factory.create[NodeType]. The parameters you pass are the building blocks: other nodes, strings, arrays of nodes, etc. It’s like LEGOs, but if you put the pieces in the wrong order, it throws a type error instead of just looking weird.
Let’s generate an interface. We need an InterfaceDeclaration, which requires a name, some modifiers (like export), and an array of PropertySignature nodes.
// Create a simple property signature: 'name: string'
const nameProperty = ts.factory.createPropertySignature(
undefined, // modifiers (e.g., readonly, public)
ts.factory.createIdentifier("name"), // the property name
undefined, // question token if optional ('?')
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier("string"),
undefined // type arguments (for generics)
)
);
// Now create the interface that contains it
const personInterface = ts.factory.createInterfaceDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], // modifiers
ts.factory.createIdentifier("Person"), // name
undefined, // type parameters (for generics)
undefined, // heritage clauses (e.g., 'extends')
[nameProperty] // members
);
Stitching a Function Together
Functions are more interesting. They’re a combination of a signature and a body. We’ll create a function statement (a declaration) that returns a Person object.
// 1. Create the parameter: 'name: string'
const nameParameter = ts.factory.createParameterDeclaration(
undefined, // modifiers
undefined, // dot-dot-dot token for rest parameters
ts.factory.createIdentifier("name"), // parameter name
undefined, // question token if optional
ts.factory.createTypeReferenceNode("string", undefined) // type
);
// 2. Create the return type annotation (': Person')
const returnTypeAnnotation = ts.factory.createTypeReferenceNode("Person", undefined);
// 3. Create the function body: '{ return { name }; }'
// The object literal { name } is a shorthand for { name: name }
const returnStatement = ts.factory.createReturnStatement(
ts.factory.createObjectLiteralExpression(
[ts.factory.createShorthandPropertyAssignment(ts.factory.createIdentifier("name"))],
false // no multi-line formatting
)
);
const functionBody = ts.factory.createBlock([returnStatement], true); // true = multi-line
// 4. Assemble the function declaration
const createPersonFunction = ts.factory.createFunctionDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], // modifiers
undefined, // asterisk token for generator functions
ts.factory.createIdentifier("createPerson"), // function name
undefined, // type parameters
[nameParameter], // parameters
returnTypeAnnotation, // return type
functionBody
);
The Magic Trick: Printing It All Out
You’ve built the AST. Now, how do you get the beautiful, formatted code? You use the Printer. This is the part that feels like magic. You hand it a node and the source file you created at the beginning (which provides the necessary context for formatting), and it gives you back a perfect string.
// Print each node individually. The printer uses the sourceFile for context.
const interfaceCode = printer.printNode(
ts.EmitHint.Unspecified,
personInterface,
sourceFile
);
const functionCode = printer.printNode(
ts.EmitHint.Unspecified,
createPersonFunction,
sourceFile
);
// Combine them. You might want to add a newline in between.
const fullGeneratedCode = `${interfaceCode}\n\n${functionCode}`;
console.log(fullGeneratedCode);
Run this, and you should get this pristine output:
export interface Person {
name: string;
}
export function createPerson(name: string): Person {
return { name };
}
The Pitfalls and the “Oh, Right” Moments
This is where the committee-written manual would end. I won’t do that to you.
- The Context Problem: Notice we created a
SourceFilepurely as a context holder for the printer. If you were generating code to be inserted into an existing file you had parsed, you would use thatSourceFileobject instead. Using the wrong one can lead to weird formatting issues. - It’s Just an AST: The generator doesn’t know about your project’s
tsconfig.json, ESLint rules, or prettier config. It outputs standard TypeScript. The formatting from the printer is good, but it might not match your project’s style. You might need to run the generated output through a formatter afterward. This is a feature, not a bug—it keeps the API simple and focused. - Whitespace Weirdness: You are in complete control of things like the multi-line parameter in
createBlock. Set it tofalse, and your function body will be{ return { name }; }on one line. The factory gives you the primitives; you decide the style. - The Biggest Mistake: Trying to build complex nodes from the bottom up in one giant, nested call. Don’t. Break it down into steps, as we did above. Assign each significant node to a well-named variable. Your sanity and your debugger will thank you. The type safety of the factory functions is your guide; follow it. If it expects an array of
TypeNode, it will stop you from passing aStringLiteral. This is your safety net.