Make TypeScript Fast
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.jsonjson
{"scripts": {"transpile": "tsc -p some-package/tsconfig.json --isolatedModules"}}
/package.jsonjson
{"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 npmnpm i -D @swc/cli @swc/core# if you use yarnyarn add -D @swc/cli @swc/core
shell
# if you use npmnpm i -D @swc/cli @swc/core# if you use yarnyarn add -D @swc/cli @swc/core
/package.jsonjson
{"scripts": {"transpile": "swc ./src -d lib"}}
/package.jsonjson
{"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.jsjs
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 sightwhile (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, distif (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 fileselse 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.jsjs
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 sightwhile (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, distif (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 fileselse 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
:
- remote cache
- scope skipping
- 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.jsjs
const ts = require("typescript");const path = require("path");const { existsSync } = require("fs");// Save the previously run ts.program to be fed inside the next calllet 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 fileshost.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 dataconst 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 tsconfigconst 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 programcompilerHost = 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-typescriptconst 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.jsjs
const ts = require("typescript");const path = require("path");const { existsSync } = require("fs");// Save the previously run ts.program to be fed inside the next calllet 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 fileshost.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 dataconst 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 tsconfigconst 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 programcompilerHost = 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-typescriptconst 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;