TypeScript codegen 101
If you’re like me and want to write code that is actually safe & reliable, then you will love this post as it dives deep into a not very well documented or known about tool amongst the web development space.
Currently most projects will often use inferred TypeScript types to provide users a fully typed experience. However, at times this can become limiting or in other use cases it does just make more sense to use some form of code generation to pragmatically generate TypeScript types based on some kind of provided input data, like a database schema for example.
Now, for super super basic implementations a basic template string will probably be fine. But if you want to write more complex code, or if you know the code being generated is slightly more verbose and complex and you want to ensure everything is still type-safe and valid, then you may want to use a tool better designed for the job.
Enter the TypeScript Compiler API. This magical and undocumented tool allows you to programmatically create and manipulate TypeScript code in a fully type-safe manner.
While this beast is insanely powerful, it is also both very daunting and complex to work with directly. As such, having used it myself when building RONIN for the past few years, thought it would be helpful for others wanting or needing to use it to have some kind of helpful tips, tricks and examples of how to use it, since there is little to no documentation on how to use the API other than this GitHub wiki and a few other blog posts out there.
Getting Started
Since in this use case we will be using the compiler API to generate code, it seems best to start by building out a quick helper utility to allow you to export whatever you build with the TSC API to some form of string that you can do whatever you want with.
import ts from 'typescript';
const nodesArray = ts.factory.createNodeArray([
// Add your nodes here
]);
const sourceFile = ts.createSourceFile(
'', // File name
'', // Source text
ts.ScriptTarget.Latest, // Language version
);
const output = ts
.createPrinter()
.printList(ts.ListFormat.MultiLine, nodesArray, sourceFile);
console.log(output);At first this may look slightly confusing, but let’s break it down a bit.
ts: The default export fortypescriptincludes everything you will need, including the factory functions for creating AST nodes.nodesArray: This is the array of nodes that we want to print. In the context of the TypeScript Compiler API, a “node” is a fundamental building block of the abstract syntax tree (AST) that represents a piece of TypeScript code. Nodes can represent various constructs such as variables, functions, classes, and more.sourceFile: This is the virtual source file we created in memory. It’s essentially a blank slate that the printer will use to generate the final output.output: This is the final string output generated by the printer. It contains the formatted TypeScript code based on the provided nodes.
Your first type
Now we have a basic setup to print some nodes, let’s test it out shall we?
const FooTypeDeclaration = ts.factory.createTypeAliasDeclaration(
undefined,
'Foo',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
);
const nodesArray = ts.factory.createNodeArray([FooTypeDeclaration]);
// [...]Now, if you run this code, you should see the following output:
type Foo = string;And just like that you now have your very own code generation tool to generate TypeScript code!
With the very basic example covered, let’s dive into a bunch of examples of how to create everything from other basic types, to more complex types like expressions and conditionals.
Primatives
If you need any kind of primitive keyword, like string, number, etc. The SyntaxKind enum is your friend:
ts.SyntaxKind.StringKeyword; // `string`
ts.SyntaxKind.NumberKeyword; // `number`
ts.SyntaxKind.BooleanKeyword; // `boolean`
ts.SyntaxKind.UnknownKeyword; // `unknown`
ts.SyntaxKind.AnyKeyword; // `any`But what if you need null? Don’t worry, there’s a factory helper for that.
ts.factory.createNull();However, it should be noted that this returns a null literal. In most cases you will need to wrap this in a helper to make it into a usable type node:
ts.factory.createLiteralTypeNode(ts.factory.createNull());Next up is array’s. Once again, there is a helper for that too.
// unknown[]
ts.factory.createArrayTypeNode(
ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
);Type aliases
We have already covered the basics of creating a basic type Foo = string;:
ts.factory.createTypeAliasDeclaration(
undefined,
'Foo',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
);type Foo = string;But if you want to add some extra complexity to it you can both add an export modifier and add a type argument.
ts.factory.createTypeAliasDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
'Foo',
[ts.factory.createTypeParameterDeclaration(undefined, 'T')],
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
);export type Foo<T> = string;Interfaces
If you need to use interfaces instead of type declarations, they’re quite similar. Here’s how you can create a basic interface:
ts.factory.createInterfaceDeclaration(
undefined,
'MyInterfaceName',
undefined,
undefined,
[
ts.factory.createPropertySignature(
undefined,
'foo',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
),
],
);interface MyInterfaceName {
foo: string;
}And just the same as type aliases, you can add modifiers or type arguments to interfaces as well.
ts.factory.createInterfaceDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
'MyInterfaceName',
[
ts.factory.createTypeParameterDeclaration(
undefined,
'T',
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
),
],
undefined,
[
ts.factory.createPropertySignature(
undefined,
'foo',
undefined,
ts.factory.createTypeReferenceNode('T'),
),
],
);export interface MyInterfaceName<T extends string> {
foo: T;
}Along with this, interfaces also include “heritage clauses” which allow you to extend other types.
ts.factory.createInterfaceDeclaration(
undefined,
'MyInterfaceName',
undefined,
[
ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
ts.factory.createExpressionWithTypeArguments(
ts.factory.createIdentifier('Record'),
[
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
],
),
]),
],
[
ts.factory.createPropertySignature(
undefined,
'foo',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
),
],
);interface MyInterfaceName extends Record<string, unknown> {
foo: string;
}Modules
Modules are a key part of code generation as they are commonly used for either overriding existing types, or declaring a new module to import types from. As such you can create a module like so:
ts.factory.createModuleDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.DeclareKeyword)],
ts.factory.createIdentifier('my-module-name'),
ts.factory.createModuleBlock([
// Any nodes you want to add to the module block
]),
);declare module 'my-module-name' {}Namespaces
While not as widely used or encouraged anymore, namespaces are still a very powerful primitive that should you need to use them can be created as follows:
ts.factory.createModuleDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier('MyNamespaceName'),
ts.factory.createModuleBlock([
// Any nodes you want to add to the module block
]),
ts.NodeFlags.Namespace,
);export namespace MyNamespaceName {}Template strings
While a newer feature, template strings are insanely powerful to create dynamic string types and be created with the following:
ts.factory.createExpressionWithTypeArguments(
ts.factory.createTemplateExpression(ts.factory.createTemplateHead('prefix_'), [
ts.factory.createTemplateSpan(
ts.factory.createIdentifier('string'),
ts.factory.createTemplateTail('_suffix'),
),
]),
undefined,
);`prefix_${string}_suffix`;Mapped types
In some niche cases you may need to create some types to re-map types, which can be useful for creating more complex types. With that you can create a mapped type like so:
const typeParamIdentifier = ts.factory.createIdentifier('T');
ts.factory.createTypeAliasDeclaration(
undefined,
'Foo',
[ts.factory.createTypeParameterDeclaration(undefined, typeParamIdentifier)],
ts.factory.createMappedTypeNode(
undefined,
ts.factory.createTypeParameterDeclaration(
undefined,
'K',
ts.factory.createTypeOperatorNode(
ts.SyntaxKind.KeyOfKeyword,
ts.factory.createTypeReferenceNode(typeParamIdentifier),
),
),
undefined,
undefined,
ts.factory.createIndexedAccessTypeNode(
ts.factory.createTypeReferenceNode(typeParamIdentifier, undefined),
ts.factory.createTypeReferenceNode('K', undefined),
),
undefined,
),
);type Foo<T> = {
[K in keyof T]: T[K];
};Unions
Unions are a powerful way to create types that can be one of many different types and can be represented as follows:
ts.factory.createUnionTypeNode([
ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral('foo')),
ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral('bar')),
]);'foo' | 'bar';Indexed access types
Indexed access types allow you to create types that represent the type of a specific property of another type. You can create an indexed access type like so:
If you need to access a property of a type T, you can use the following pattern:
ts.factory.createIndexedAccessTypeNode(
ts.factory.createTypeReferenceNode('T'),
ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral('foo')),
);T['foo'];Expressions
Expressions are an advanced pattern that, in my usage, are most often used for things like acessing types from within a namespace. To create an expression, you can use the following pattern:
ts.factory.createExpressionWithTypeArguments(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('MyNamespaceName'),
'Foo',
),
[ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)],
);MyNamespaceName.Foo<any>;Conditional types
Similar to mapped types, conditional types are an advanced pattern that allows you to create types based on conditions. You can create a conditional type like so:
ts.factory.createConditionalTypeNode(
ts.factory.createTypeReferenceNode('T'),
ts.factory.createLiteralTypeNode(ts.factory.createNull()),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
);T extends null ? any : anyThe caveats
That was a lot very quickly so lets zoom out for a moment and consider some of the caveats of using the TypeScript compiler API.
While very powerful, the TypeScript compiler API can be complex and difficult to work with at times. Here are a few caveats to keep in mind:
-
Slow DX: Importing the compiler API is so large that some editors or IDE’s may have their performance when performing file saves, etc dramatically affected.
-
Bundle size: At the time of writing, if you include the comiler API into your application it will add roughly 3.5mb worth of JavaScript. So this is something that would be much better included as part of a hosted service for a client to connect to, or shipped as part of some kind of CLI for users to use.
-
Complex: While I have listed a lot here, this has only been through a LOT of trial and error myself. There is little to no public documentation on how to use the API so if you do want to do something yourself or anything very complex you will very likely find yourself spending a lot of time tinkering and asking AI assistants for help.
More reading
If you’re interested in learning more about the TypeScript compiler API or want to have a read more into it, I highly recommend checking out some of the other posts & guide I found while initially looking into how to use the TypeScript compiler API: