tscw-config
v1.1.2
Published
Run tsc on files with tsconfig respected
Readme
Motivation
Running tsc locally will compile the closest project defined by a
tsconfig.json, or you can compile a set of TypeScript files by passing in a glob of files you want. When input files are specified on the command line, tsconfig.json files are ignored. - tsc CLI Options
tscw-config lets you run tsc on files while keeping tsconfig.json respected.
[!NOTE]
tscw-configstands for tsc with config.
Use cases
A common use case for running tsc on certain files is when used in a pre-commit hook. e.g. lint-staged, pre-commit.
For example, you may want to type-check staged files by running tsc --noEmit foo.ts bar.ts. In this case tsc will ignore the tsconfig.json, using -p tsconfig.json with files will result in an error.
You can explicitly pass the CLI options in. e.g. --strict --allowSyntheticDefaultImports ... to tsc, but that can be tedious.
Using tscw is much easier: tscw --noEmit foo.ts bar.ts -p tsconfig.json.
[!IMPORTANT]
- There're cases that declaration files need to be included even though you just want to type-check some files, you can specify the declaration directory with
--includeDeclarationDir, for example:npx tscw --noEmit foo.ts --includeDeclarationDir @types, it will include all the files that end with.d.tsin@typesand any sub-directories. If you need more fine-grained control, see Include declaration files.tscwcan be used with pre-commit, see Recipes.
Getting Started
tscw seamlessly integrates with most popular package managers, including:
- npm
- pnpm
- Yarn
- Yarn (Plug’n’Play)
npm:
npm i -D tscw-configpnpm:
pnpm add -D tscw-configyarn:
yarn add -D tscw-configUsage
After installing tscw-config, you can use tscw the same way you use tsc, but tscw will not ignore your tsconfig.json when files are specified.
By default, tscw uses the root tsconfig.json if no one is specified.
# root tsconfig.json is used
npx tscw foo.ts
# specify a tsconfig
npx tscw --noEmit foo.ts -p ./config/tsconfig.json
# or
npx tscw --noEmit foo.ts --project ./config/tsconfig.json
# match ./foo.ts, ./bar.ts ...
npx tscw *.ts
# match ./foo/baz.ts, ./bar/foo.ts ...
npx tscw **/*.ts
# include declaration files directory, by default, it recursively searches for files that end with .d.ts in the specified directory
npx tscw --noEmit --includeDeclarationDir ./@types
# you can even use it without any files specified
npx tscw --noEmit # it is the same as npx tsc --noEmitHere's an example of using it in a .lintstagedrc.js file. You can also check out the .lintstagedrc.mjs in this project.
/**
* Passing absolute path is fine, but relative path is cleaner in console.
* @param {string[]} files
*/
const typeCheck = files => {
const cwd = process.cwd();
const relativePaths = files.map(file => path.relative(cwd, file)).join(" ");
// if you need to include declaration files, use --includeDeclarationDir path-to-declaration-dir
return `npx tscw --noEmit ${relativePaths} --includeDeclarationDir @types`;
};
export default {
"**/*.{ts,mts,cts,tsx}": [prettier, typeCheck, eslint],
};if your're using yarn PnP, instead of using npx tscw, use yarn tscw:
yarn tscw foo.ts[!NOTE]
tscwsupports all CLI options supported bytsc. Other than that, you can use--includeDeclarationDirto include declaration files.
API
tscw-config also exposes a function to run tsc programmatically, but in most cases you should use the CLI tscw:
import tscw from 'tscw-config';
const result = await tscw`foo.ts --noEmit -p tsconfig.json`
// or
const result = await tscw("foo.ts", "--noEmit", "-p", "tsconfig.json");Return type
type Result = Promise<SpawnResult | SpawnError>;
interface SpawnResult {
pid: number;
exitCode: number;
stdout: string;
stderr: string;
}
interface SpawnError {
pid: null;
exitCode: number;
stderr: string;
stdout: null;
}In the following scenarios, the function returns Promise<SpawnError>:
- No
package.jsonis found in the root of your project. - No
tsconfig.jsonis found in the root of your project if no tsconfig is passed to the function. - Specified files not found.
- Missing argument for
-por--project.
import tscw from "tscw-config";
const result = await tscw`foo.ts --noEmit -p noSuchFile.json`;
/*
result: {
pid: null,
exitCode: 1,
stderr: "Can't find noSuchFile.json",
stdout: null,
};
*/Otherwise the function returns Promise<SpawnResult>, which means that the args are successfully passed to tsc.
Under the hood, tscw uses spawn to run tsc, the result from tsc is stored in result.stdout even when exitCode is not 0.
// containTypeError.ts
type A = number;
const _a: A = "";import tscw from "tscw-config";
const result1 = await tscw`containTypeError.ts --noEmit -p tsconfig.json --pretty false`;
console.log(result1.pid); // number
console.log(result1.exitCode); // 1
console.log(result1.stdout); // "containTypeError.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.\r\n"
console.log(result1.stderr); // ""
const result2 = await tscw`noTypeError.ts --noEmit -p tsconfig.json`;
console.log(result2.pid); // number
console.log(result2.exitCode); // 0
console.log(result2.stdout); // ""
console.log(result2.stderr); // ""[!NOTE]
By default,stdoutcontains ANSI escape code, if you wantstdoutto be plain text, pass--pretty falseto the function./* "\x1B[96mcontainTypeError.ts\x1B[0m:\x1B[93m3\x1B[0m:\x1B[93m7\x1B[0m - \x1B[91merror\x1B[0m\x1B[90m TS2322: \x1B[0mType 'string' is not assignable to type 'number'.\r\n" + '\r\n' + '\x1B[7m3\x1B[0m const _a: A = "";\r\n' + '\x1B[7m \x1B[0m \x1B[91m ~~\x1B[0m\r\n' + '\r\n' + '\r\n' + 'Found 1 error in containTypeError.ts\x1B[90m:3\x1B[0m\r\n' + '\r\n' */Notice that when you pass a file to the function using a relative path, it is relative to the current working directory (cwd) when you run the script, not relative to the file where this function is used.
[!IMPORTANT]
In most cases, you should use the CLItscwwhen you want the process to fail if the compilation fails. For example in CI pipeline, lint-staged, etc. Executing the function will not cause the process to fail even if the returnedexitCodeis not0, unless you explicitly exit the process with the returnedexitCode, liketscwdoes.
How it works
- Argument Parsing:
- The script processes user-specified arguments to handle flags and file paths.
- Finding
tsconfig.json:- If no
tsconfig.jsonfile is specified via the-por--projectflag, the nearesttsconfig.jsonfile will be used for the current workspace. - The script first looks for the current working directory, if not found, it goes all the way up until the level where
package.jsonis located.
- If no
- Temporary File:
- A temporary file is created to store the content of the
tsconfig.jsonfile being used. - It adds/replaces the
"files"field with the files specified. - It empties the
"include"field.
- A temporary file is created to store the content of the
- Running
tsc:- It runs
tscwith the temp file and any specified flags.
- It runs
- Cleanup:
- The script removes the temporary file when the script exits or receives certain signals(SIGINT, SIGHUP, SIGTERM).
[!NOTE]
Windows has limited support for process signals compared to Unix-like systems, especially whenprocess.killis used to terminate a process, signal will not be caught by the process, therefore cleaning up the temp file is a problem. See Signal events.Technically, to fix the cleanup problem, using
options.detachedfor a child process would be enough, but lint-staged takes the approach of terminating all the child processes by callingprocess.killon the tasks that areKILLED(When multiple tasks are running concurrently, if one taskFAILED, other tasks will beKILLED).In order to properly fix this problem,
tscw-configcreates a daemon to handle the cleanup task if it is running on Windows. The daemon will exit gracefully after the temporary file is deleted or, at most, after 1 minute.
Recipes
lintstaged
Check out the .lintstagedrc.mjs in this project.
pre-commit
You can write a local hook with the tscw API, it's pretty simple:
pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: type-checking
name: Check Type
entry: ./check-type.js
args: ["--noEmit"]
language: node
types_or: [ts, tsx]check-type.js
Note: Remember to make it an executable: chmod +x check-type.js
#!/usr/bin/env node
const { exit } = require("process");
const { join } = require("node:path");
const { readdirSync } = require("node:fs");
const tscw = require("tscw-config");
/**
* @param {string} dir
* @param {RegExp} regex
*
* @returns {string[]}
*/
const getFilesRecursivelySync = (dir, regex) => {
const files = readdirSync(dir, { withFileTypes: true });
/** @type {string[]} */
let result = [];
for (const file of files) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
result = result.concat(getFilesRecursivelySync(fullPath, regex));
} else if (regex.test(file.name)) {
result.push(fullPath);
}
}
return result;
};
void (async () => {
const args = process.argv.slice(2);
// Include all the declaration files for the current project if needed.
const declarationFiles = getFilesRecursivelySync(
"./@types" /* adjust the dirname for your project */,
/\.d\.ts$/,
).join(" ");
try {
const child = await tscw`${args.join(" ")} ${declarationFiles}`;
// You can also use the --includeDeclarationDir flag, e.g. tscw`${args.join(" ")} --includeDeclarationDir @types`
if (child.stdout) {
console.log(child.stdout);
} else {
console.log(child.stderr);
}
exit(child.exitCode);
} catch (e) {
console.error(e);
exit(1);
}
})();[!NOTE]
This project useshuskyfor pre-commit hooks, it seems to have some conflicts withpre-commit, so we currently don't provide apre-commithook for consumption, but you can always usetscw's API to write a local hook.
Troubleshooting
Include declaration files
Under the hood, tscw creates a copy of the tsconfig.json and removes the include filed. This means that all the declaration files specified it in the include field will not be respected when you run tscw.
Example
Consider that there're two files:
foo.tsfoo.module.css
// foo.ts
import styles from "./foo.module.css";
console.log(styles);npx tscw --noEmit foo.tsfoo.ts:1:20 - error TS2307: Cannot find module './foo.module.css' or its corresponding type declarations.
1 import styles from "./foo.module.css"
Found 1 error in foo.ts:1This can easily be solved by including a necessary declaration file in the include field of your tsconfig.json, but when tscw is run, it creates a copy of that tsconfig.json with the include field stripped out. Here're some workarounds:
Workarounds
1. Include the declaration in the file
/// <reference path="path-to-declaration.d.ts" />
// or use import
import "path-to-declaration";
import styles from "./foo.module.css";You can simply include the declaration file in the file. But this can quickly get messy if you have multiple files that need declaration file(s).
2. Pass declaration files to tscw
You can use the --includeDeclarationDir flag to include your declaration files directory, tscw will include all the files that end with .d.ts in that directory and all its sub-directories
Here's an example using it in a lintstagedrc file, you can check out the .lintstagedrc.mjs in this project.
/**
* Passing absolute path is fine, but relative path is cleaner in console.
* @param {string[]} files
*/
const typeCheck = files => {
const cwd = process.cwd();
const relativePaths = files.map(file => relative(cwd, file)).join(" ");
return `npx tscw --noEmit --includeDeclarationDir ./@types ${relativePaths}`;
};If you need more fine-grained control, you can include the declaration files manually, for example:
/**
* @param {string} dir
* @param {RegExp} regex
*
* @returns {string[]}
*/
const getFilesRecursivelySync = (dir, regex) => {
const files = readdirSync(dir, { withFileTypes: true });
/** @type {string[]} */
let result = [];
for (const file of files) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
result = result.concat(getFilesRecursivelySync(fullPath, regex));
} else if (regex.test(file.name)) {
result.push(fullPath);
}
}
return result;
};
/**
* Passing absolute path is fine, but relative path is cleaner in console.
* @param {string[]} files
*/
const typeCheck = files => {
const cwd = process.cwd();
const relativePaths = files.map(file => relative(cwd, file)).join(" ");
// Include all the declaration files for the current project.
const declarationFiles = getFilesRecursivelySync("./@types", /\.d\.ts$/).join(" ");
return `npx tscw --noEmit ${relativePaths} ${declarationFiles}`;
};