Skip to main content

Make TypeScript Fast

version 2

TypeScript is a superset on top of JavaScript that adds type safety checks and downlevel conversion of modern ECMAScript-like syntax with types to a target ECMAScript level. In a monorepo, we are interested in how to accelerate two aspects of TypeScript: transpilation and type checking. Let's go!

Fastest transpilation

TypeScript's npm package typescript comes with a CLI program called tsc. This program will do type checking as well as transpilation. We can configure it to transpile code only. This would speed up the compilation by doing work on a file-by-file basis (not quite true, but close enough):

tsc -p some-package/tsconfig.json --isolatedModules
tsc -p some-package/tsconfig.json --isolatedModules

This is something you can place inside your package's package.json like so:

/package.json
json
{
"scripts": {
"transpile": "tsc -p some-package/tsconfig.json --isolatedModules"
}
}
/package.json
json
{
"scripts": {
"transpile": "tsc -p some-package/tsconfig.json --isolatedModules"
}
}

Recently, many pure transpilers have been made to make transpilation even faster. You can use any of these packages: swc (Rust-based), esbuild (Go-based), sucrase (Node.js-based). In this document, we will demonstrate with swc:

shell
# if you use npm
npm i -D @swc/cli @swc/core
# if you use yarn
yarn add -D @swc/cli @swc/core
shell
# if you use npm
npm i -D @swc/cli @swc/core
# if you use yarn
yarn add -D @swc/cli @swc/core
/package.json
json
{
"scripts": {
"transpile": "swc ./src -d lib"
}
}
/package.json
json
{
"scripts": {
"transpile": "swc ./src -d lib"
}
}

You could also skip @swc/cli package, and make your own custom worker script (configure this inside the lage.config.js pipeline as a "worker" type):

/scripts/workers/swc-worker.js
js
const path = require("path");
const fs = require("fs/promises");
const swc = require("@swc/core");
module.exports = async function transpile(data) {
const { target } = data;
const queue = [target.cwd];
// recursively transpile everything in sight
while (queue.length > 0) {
const dir = queue.shift();
let entries = await fs.readdir(dir, { withFileTypes: true });
for (let entry of entries) {
const fullPath = path.join(dir, entry.name);
// some basic "excluded directory" list: node_modules, lib, tests, dist
if (
entry.isDirectory() &&
entry.name !== "node_modules" &&
entry.name !== "lib" &&
entry.name !== "tests" &&
entry.name !== "dist"
) {
queue.push(fullPath);
}
// if file extension is .ts - you maybe want to include .tsx here as well
// for repos that have TSX files
else if (entry.isFile() && entry.name.endsWith(".ts")) {
const swcOutput = await swc.transformFile(fullPath);
const dest = fullPath
.replace(/([/\\])src/, "$1lib")
.replace(".ts", ".js");
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.writeFile(dest, swcOutput.code);
}
}
}
};
/scripts/workers/swc-worker.js
js
const path = require("path");
const fs = require("fs/promises");
const swc = require("@swc/core");
module.exports = async function transpile(data) {
const { target } = data;
const queue = [target.cwd];
// recursively transpile everything in sight
while (queue.length > 0) {
const dir = queue.shift();
let entries = await fs.readdir(dir, { withFileTypes: true });
for (let entry of entries) {
const fullPath = path.join(dir, entry.name);
// some basic "excluded directory" list: node_modules, lib, tests, dist
if (
entry.isDirectory() &&
entry.name !== "node_modules" &&
entry.name !== "lib" &&
entry.name !== "tests" &&
entry.name !== "dist"
) {
queue.push(fullPath);
}
// if file extension is .ts - you maybe want to include .tsx here as well
// for repos that have TSX files
else if (entry.isFile() && entry.name.endsWith(".ts")) {
const swcOutput = await swc.transformFile(fullPath);
const dest = fullPath
.replace(/([/\\])src/, "$1lib")
.replace(".ts", ".js");
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.writeFile(dest, swcOutput.code);
}
}
}
};

Fastest Type Checking

The industry is abuzz about how to replace TypeScript with a faster transpiler. There is still no open sourced TypeScript type checker that retains the full fidelity of the work that is done by tsc.

TypeScript compiler is a single threaded program, so previously the fastest way to type check without caching (i.e fastest first run) is to flatten everything into a single TS program with all the TypeScript source files found inside monorepo. This indeed is currently the fastest way to type check, but we can do better.

In particular, we would like to have the best of these features of lage:

  1. remote cache
  2. scope skipping
  3. pipeline across workers (multi-core)

A naive approach to achieving a faster build would be to subdivide the TypeScript project into packages, each with its own package.json script "build": "tsc -p tsconfig.json". Builds of these packages can be cached and executed in parallel topologically. This is subtly different than the project references feature of TypeScript.

:note: Project references are not preferable because it incurs an overhead of resolution of modules as well as having a tool-specific cache that isn't hooked up with a remote cache

This solution will scale up to a certain degree. The speed up is highly dependent on the shape of the package dependency graph. This is because (1) remote caching, (2) scope skipping, and even a distributed execution (not present in lage (yet?)) are highly dependent on the the shape of the graph.

To truly achieve the optimal type checker that can compete with the single flattened TS project strategy, we must understand why the flattened project is faster in a complex repo. The answer is that in a divided project, TS is spending a large amount of time on re-processing source files. You can see this in a trace of a single package: much of the time is in ts.findSourceFile() processing the d.ts files from the package dependencies. Even with skipLibCheck, we still have to load type information from these dependencies into memory each time. A single compilation for all packages would have the ability to re-use this from memory.

lage workers are here to rescue us from the single-threaded, no-remote-cache bleak state! lage has been applied inside various 10+ million lines of code repositories and has shown to cut type checking time by at least 2 (build agents are slower than local development machines).

/scripts/workers/tsc-worker.js
js
const ts = require("typescript");
const path = require("path");
const { existsSync } = require("fs");
// Save the previously run ts.program to be fed inside the next call
let oldProgram;
let compilerHost;
/** this is the patch to ts.compilerHost that retains sourceFiles in a Map **/
function createCompilerHost(compilerOptions) {
const host = ts.createCompilerHost(compilerOptions, true);
const sourceFiles = new Map();
const originalGetSourceFile = host.getSourceFile;
// monkey patch host to cache source files
host.getSourceFile = (
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile
) => {
if (sourceFiles.has(fileName)) {
return sourceFiles.get(fileName);
}
const sourceFile = originalGetSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile
);
sourceFiles.set(fileName, sourceFile);
return sourceFile;
};
return host;
}
async function tsc(data) {
const { target } = data; // Lage target data
const pathString = path.normalize(target.cwd);
const packageString = pathString.substring(pathString.lastIndexOf("\\") + 1);
const tsconfigFile = "tsconfig.lage.json";
const tsconfigJsonFile = path.join(target.cwd, tsconfigFile);
if (!existsSync(tsconfigJsonFile)) {
// this package has no tsconfig.json, skipping work!
return;
}
// Parse tsconfig
const configParserHost = parseConfigHostFromCompilerHostLike(
compilerHost ?? ts.sys
);
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
tsconfigJsonFile,
{},
configParserHost
);
if (!parsedCommandLine) {
throw new Error("Could not parse tsconfig.json");
}
const compilerOptions = parsedCommandLine.options;
// Creating compilation host program
compilerHost = compilerHost ?? createCompilerHost(compilerOptions);
// The re-use of oldProgram is a trick we all learned from gulp-typescript, credit to ivogabe
// @see https://github.com/ivogabe/gulp-typescript
const program = ts.createProgram(
parsedCommandLine.fileNames,
compilerOptions,
compilerHost,
oldProgram
);
oldProgram = program;
const errors = {
semantics: program.getSemanticDiagnostics(),
declaration: program.getDeclarationDiagnostics(),
syntactic: program.getSyntacticDiagnostics(),
global: program.getGlobalDiagnostics()
};
const allErrors = [];
try {
program.emit();
} catch (e) {
console.log(e.messageText);
throw new Error("Encountered errors while emitting");
}
let hasErrors = false;
for (const kind of Object.keys(errors)) {
for (const diagnostics of errors[kind]) {
hasErrors = true;
allErrors.push(diagnostics);
}
}
if (hasErrors) {
console.log(
ts.formatDiagnosticsWithColorAndContext(allErrors, compilerHost)
);
throw new Error("Failed to compile");
} else {
console.log("Compiled successfully\n");
return;
}
}
function parseConfigHostFromCompilerHostLike(host) {
return {
fileExists: (f) => host.fileExists(f),
readDirectory(root, extensions, excludes, includes, depth) {
return host.readDirectory(root, extensions, excludes, includes, depth);
},
readFile: (f) => host.readFile(f),
useCaseSensitiveFileNames: host.useCaseSensitiveFileNames,
getCurrentDirectory: host.getCurrentDirectory,
onUnRecoverableConfigFileDiagnostic: (d) => {
throw new Error(ts.flattenDiagnosticMessageText(d.messageText, "\n"));
},
trace: host.trace
};
}
module.exports = tsc;
/scripts/workers/tsc-worker.js
js
const ts = require("typescript");
const path = require("path");
const { existsSync } = require("fs");
// Save the previously run ts.program to be fed inside the next call
let oldProgram;
let compilerHost;
/** this is the patch to ts.compilerHost that retains sourceFiles in a Map **/
function createCompilerHost(compilerOptions) {
const host = ts.createCompilerHost(compilerOptions, true);
const sourceFiles = new Map();
const originalGetSourceFile = host.getSourceFile;
// monkey patch host to cache source files
host.getSourceFile = (
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile
) => {
if (sourceFiles.has(fileName)) {
return sourceFiles.get(fileName);
}
const sourceFile = originalGetSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile
);
sourceFiles.set(fileName, sourceFile);
return sourceFile;
};
return host;
}
async function tsc(data) {
const { target } = data; // Lage target data
const pathString = path.normalize(target.cwd);
const packageString = pathString.substring(pathString.lastIndexOf("\\") + 1);
const tsconfigFile = "tsconfig.lage.json";
const tsconfigJsonFile = path.join(target.cwd, tsconfigFile);
if (!existsSync(tsconfigJsonFile)) {
// this package has no tsconfig.json, skipping work!
return;
}
// Parse tsconfig
const configParserHost = parseConfigHostFromCompilerHostLike(
compilerHost ?? ts.sys
);
const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
tsconfigJsonFile,
{},
configParserHost
);
if (!parsedCommandLine) {
throw new Error("Could not parse tsconfig.json");
}
const compilerOptions = parsedCommandLine.options;
// Creating compilation host program
compilerHost = compilerHost ?? createCompilerHost(compilerOptions);
// The re-use of oldProgram is a trick we all learned from gulp-typescript, credit to ivogabe
// @see https://github.com/ivogabe/gulp-typescript
const program = ts.createProgram(
parsedCommandLine.fileNames,
compilerOptions,
compilerHost,
oldProgram
);
oldProgram = program;
const errors = {
semantics: program.getSemanticDiagnostics(),
declaration: program.getDeclarationDiagnostics(),
syntactic: program.getSyntacticDiagnostics(),
global: program.getGlobalDiagnostics()
};
const allErrors = [];
try {
program.emit();
} catch (e) {
console.log(e.messageText);
throw new Error("Encountered errors while emitting");
}
let hasErrors = false;
for (const kind of Object.keys(errors)) {
for (const diagnostics of errors[kind]) {
hasErrors = true;
allErrors.push(diagnostics);
}
}
if (hasErrors) {
console.log(
ts.formatDiagnosticsWithColorAndContext(allErrors, compilerHost)
);
throw new Error("Failed to compile");
} else {
console.log("Compiled successfully\n");
return;
}
}
function parseConfigHostFromCompilerHostLike(host) {
return {
fileExists: (f) => host.fileExists(f),
readDirectory(root, extensions, excludes, includes, depth) {
return host.readDirectory(root, extensions, excludes, includes, depth);
},
readFile: (f) => host.readFile(f),
useCaseSensitiveFileNames: host.useCaseSensitiveFileNames,
getCurrentDirectory: host.getCurrentDirectory,
onUnRecoverableConfigFileDiagnostic: (d) => {
throw new Error(ts.flattenDiagnosticMessageText(d.messageText, "\n"));
},
trace: host.trace
};
}
module.exports = tsc;