57

I have several files I want to compile using a script. These files rely on the paths compile option to resolve its modules. As I want to target these files specifically I am bound to supplying them to tsc (as I don't want to create a separate tsconfig.json that targets these files for this task)

I've looked at the option to pass the --path parameter to tsc, but this is not allowed (error TS6064: Option 'paths' can only be specified in 'tsconfig.json' file.)

Can I somehow only compile specific .ts files while using the paths option?

Update (22-06-17)

As per request some specific examples:

The relevant settings in the tsconfig.json file are the following:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "Components/*": [
        "./src/Components/*"
      ]
    }
  }
}

so the relevant part is the paths setting, where you can create shorthands to import files from a certain directory. so you can import a file like import {header} from 'Components/header.ts' in stead of import {header} from '../../Components/header.ts'

But I need to compile specific files from the command line. But if I try:

tsc --project tsconfig.json entryFile.ts

it will give me the error:

error TS5042: Option 'project' cannot be mixed with source files on a command line.

and if I try to provide the paths option to the cli I get the following error:

error TS6064: Option 'paths' can only be specified in 'tsconfig.json' file.
Jasper Schulte
  • 7,181
  • 7
  • 23
  • 19

6 Answers6

53

Specifying the input files on the command line voids your tsconfig.json.

When input files are specified on the command line, tsconfig.json files are ignored.

From the TypeScript docs.

So, there's no other option than to use a tsconfig.json with the proper 'include' (or exclude) specification of the files. The good news is that in the same docs you will find:

A tsconfig.json file can inherit configurations from another file using the extends property.

So what you can do is overwrite the include property of your tsconfig with something like this:

{
  "extends": "./tsconfig.json",
  "include": [
      "src/root/index.ts"
  ]
}

So you could make a script that:

  1. Creates a temporary 'extends tsconfig' file with the desired 'include' property
  2. Calls tsc with --project specified as that temporary tsconfig

One could argue that this is very complicated and question why they made the decision to not support this scenario properly. However, the question remains why one would like to compile just one file of a project. While developing, you can use the watch (-w) option to compile changes files, and upon building a full project you'll probably want to compile exactly that, the full project.

Bram
  • 4,232
  • 20
  • 23
  • 11
    "However, the question remains why one would like to compile just one file of a project." -- The case I'm running into is real time linting of a file you're editing (in my case through vim-ale). – pgraham Jan 19 '18 at 16:28
  • 2
    I wanted to [generate declarations](https://stackoverflow.com/a/54746095/581205) for a single file, but for it to compile, I had to specify options, which are already included in the tsconfig. It was just `--target ES5 --jsx react`, so no big deal, but there are many more options there, which may be relevant, too (or not; I'm new to TS and I didn't wrote the config). – maaartinus Apr 29 '19 at 00:53
  • 6
    RE: "why one would like to compile just one file of a project" - I am currently trying to create a simple repro to debug a problem – Adam Marshall Jul 26 '19 at 08:27
  • 9
    why...because I am writing a script and just want to test the one – str8up7od Sep 06 '19 at 14:25
  • 6
    Why? Because I want to pass the `--traceResolution` flag and only get the output for one file, as otherwise, it's too difficult to find what I'm looking for in the flood of output for a large project. – Hank Schultz Dec 31 '19 at 19:19
  • 3
    Same here -- just trying to debug things, so I want to see what tsc generates for this one file. – GaryO May 19 '20 at 21:51
  • Another case here: traceResolution output is ridiculously long. I was able to speed up my use-case (splitting huge change into smaller PRs) with this: ```tsc -p some/path/tsconfig.temp.json --traceResolution | ggrep "was successfully resolved to" | ggrep -v "node_modules"``` – SgtPooki Oct 29 '20 at 17:06
  • 1
    Why? Because i want to further obfuscate the output of tsc. – Fandi Susanto Feb 14 '21 at 16:59
  • 1
    This transpiled all the files included by the only one listed in "include." I just want one single file transpiled, am I in the wrong place? – Seph Reed Jun 21 '21 at 18:45
13

I was in a similar need for making sure the project will compile using lint-staged. If the combination of tsconfig.json and file paths were allowed, you could use "tsc" directly as a lint-staged command.

As a workaround I created the following helper script scripts/tsc-lint.sh:

#!/bin/bash -e

TMP=.tsconfig-lint.json
cat >$TMP <<EOF
{
  "extends": "./tsconfig.json",
  "include": [
EOF
for file in "$@"; do
  echo "    \"$file\"," >> $TMP
done
cat >>$TMP <<EOF
    "unused"
  ]
}
EOF
tsc --project $TMP --skipLibCheck --noEmit

Then the lint-staged config contains:

  "lint-staged": {
    "{src,tests}/**/*.ts": [
      "scripts/tsc-lint.sh"
    ]
  },

The file .tsconfig-lint.json is added to .gitignore.

Sampo
  • 4,308
  • 6
  • 35
  • 51
  • 2
    I'd recommend replacing `"unused"` with `"**/*.d.ts"`. – Gunar Gessner Mar 17 '21 at 12:31
  • This was still producing type errors outside my staged files so I replaced the last line with this: `FILES_WITH_ERRORS=$(tsc --project $TMP --noEmit --skipLibCheck | cut -d '(' -f 1); for file in "$@"; do grep -v "$file"<<<"$FILES_WITH_ERRORS" >/dev/null; done` – Gunar Gessner Mar 17 '21 at 13:21
  • When I add multiple files in this temporary `tsconfig.json`, `tsc` will stop checking more files if any of them fails. Does it actually checks all the files in your include array even if there are type errors in more than just one? – Thomas Soos Aug 09 '22 at 16:39
12

Just to build upon @Bram's answer...

What I do in my project is have two tsconfigs:

project/
   dist/
   docs/
   src/
   package.json
   tsconfig.json
   tsconfig-build.json

So I have a tsconfig-build.json (because I don't want docs/ included in my build):

// tsconfig-build.json

{
    "extends": "./tsconfig.json",
    "include": [
        "src/**/*"
    ]
}

Then you could have a line in package.json:

// package.json
  ...
  
  "scripts": {

    ...
    
    "build": "tsc -p tsconfig-build.json"
  },
ptim
  • 14,902
  • 10
  • 83
  • 103
prograhammer
  • 20,132
  • 13
  • 91
  • 118
  • 2
    build command should be 1 of the following: `tsc -p tsconfig-build.json` OR `tsc --project tsconfig-build.json` – P. Avery Aug 19 '20 at 17:11
  • This is exactly what I'd like to do, but unfortunately, `-p` seems in incompatible with TS projects in NPM workspaces which use references and the `composite` property – ptim May 21 '23 at 23:53
3

--noResolve is the key here: https://www.typescriptlang.org/tsconfig/#noResolve

As an example:

// tsconfig.myFile.json
{
  // "extends": "./tsconfig.json",
  "include": [
    "myFile.ts"
  ],
    "compilerOptions": {
        "noResolve": true
    }
}

$ tsc --project tsconfig.myFile.json

There are also ways of including --noResolve and some output flag to do this with just command line.

Seph Reed
  • 8,797
  • 11
  • 60
  • 125
2

Building on @sampo's answer.

We can create a temporary .tsconfig-lint.json in each package directory, that includes a list of staged TS files belonging under each respective directory.

It will run a tsc for each package and report any status codes that are not 0.

// <root>/tsc-lint.js

// Usage: `yarn lint-staged` (doesn't support --relative)
// In package.json."lint-staged"."*.{ts,tsx}": `node ./tsc-lint.js`
// Run from workspace root

const chalk = require('chalk');
const spawn = require('cross-spawn');
const fs = require('fs');
const path = require('path');

// Relative path of package locations (relative to root)
const packages = [
  'packages/a',
  'packages/b',
  'packages/c',
];

const TSCONFIG_LINT_FILE_NAME = '.tsconfig-lint.json';

const files = process.argv.slice(2);

Promise.all(
  packages.map((package) => {
    const absolutePackagePath = path.resolve(package);

    const includedFiles = files.filter((file) => file.startsWith(absolutePackagePath));

    if (includedFiles.length) {
      const tsconfig = {
        extends: './tsconfig.json',
        include: [...includedFiles, '**/*.d.ts'],
      };

      const tsconfigLintPath = `${package}/${TSCONFIG_LINT_FILE_NAME}`;

      fs.writeFileSync(tsconfigLintPath, JSON.stringify(tsconfig, null, 2));

      return spawn.sync(path.resolve('./node_modules/.bin/tsc'), [
        '--project',
        tsconfigLintPath,
        '--noEmit',
        '--skipLibCheck',
      ]);
    }

    return undefined;
  }),
)
  .then((children) => Promise.all(children.filter((child) => child && child.status !== 0)))
  .then((children) => Promise.all(children.map((child) => child.stdout)))
  .then((errors) => {
    if (errors.length) {
      throw errors;
    }

    return errors;
  })
  .catch((error) => {
    console.error(chalk.red(error));

    process.exit(2);
  });
// <root>/.gitignore
.tsconfig-lint.json
0

I achieved exactly this using the compiler API. This is a (greatly) modifed version of their minimal compiler example.

import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import * as dm from '../src-ts/deep-merge-objects';
function compile(fileNames: string[], options: ts.CompilerOptions): void {
  const program = ts.createProgram(fileNames, options);
  const emitResult = program.emit();

  const allDiagnostics = ts
    .getPreEmitDiagnostics(program)
    .concat(emitResult.diagnostics);

  allDiagnostics.forEach((diagnostic) => {
    if (diagnostic.file) {
      const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(
        diagnostic.start!
      );
      const message = ts.flattenDiagnosticMessageText(
        diagnostic.messageText,
        '\n'
      );
      console.log(
        `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`
      );
    } else {
      console.log(
        ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
      );
    }
  });

  const exitCode = emitResult.emitSkipped ? 1 : 0;
  console.log(`Process exiting with code '${exitCode}'.`);
  process.exit(exitCode);
}

function main(): void {
  fs.mkdirSync('./dev-out',{recursive:true})
  cp.execSync('cp -r ./testlib-js ./dev-out/');

  const tscfgFilenames = [
    '@tsconfig/node14/tsconfig.json',
    path.join(process.cwd(), 'tsconfig.base.json'),
    path.join(process.cwd(), 'tsconfig.json'),
  ];
  const tscfg = tscfgFilenames.map((fn) => require(fn).compilerOptions);
  const compilerOptions: ts.CompilerOptions = dm.deepMerge(
    dm.deepMergeInnerDedupeArrays,
    ...tscfg
  );

  if (compilerOptions.lib && compilerOptions.lib.length)
    compilerOptions.lib = compilerOptions.lib.map((s) => 'lib.' + s + '.d.ts');
  console.log(JSON.stringify(compilerOptions, null, 2));
  compile(process.argv.slice(2), compilerOptions);
}
try {
  main();
  if (process.exitCode === 1) console.log('compiler produced no output');
} catch (e) {
  console.error(e.message);
  process.exitCode = 2;
}

The gotcha's were

  • Reading in the config file(s). That was not included in the original example.
  • Merging the config files in order. (Not a problem if you use a single file).
  • Transforming the compilerOptions.lib entries
if (compilerOptions.lib && compilerOptions.lib.length)
    compilerOptions.lib = compilerOptions.lib.map((s) => 'lib.' + s + '.d.ts');

E.g., "es2020" is changed to "lib.es2020.d.ts".

The generic object merging code is here:

// adapted from  adrian-marcelo-gallardo
// https://gist.github.com/ahtcx/0cd94e62691f539160b32ecda18af3d6#gistcomment-3257606
//
type objectType = Record<string, any>;

export const isObject = (obj: unknown): obj is objectType => {
  return <boolean>obj && typeof obj === 'object';
};
export function deepMerge(
  deepMergeInner: (target: objectType, source: objectType) => objectType,
  ...objects: objectType[]
): objectType {
  if (objects.length === 0) return {};
  if (objects.length === 1) return objects[0];
  if (objects.some((object) => !isObject(object))) {
    throw new Error('deepMerge: all values should be of type "object"');
  }
  const target = objects.shift() as objectType;
  //console.log(JSON.stringify(target,null,2))
  let source: objectType;
  while ((source = objects.shift() as objectType)) {
    deepMergeInner(target, source);
    //console.log(JSON.stringify(target,null,2))
  }
  return target;
}

export function deepMergeInnerDedupeArrays(
  target: objectType,
  source: objectType
): objectType {
  function uniquify(a: any[]): any[] {
    return a.filter((v, i) => a.indexOf(v) === i);
  }
  Object.keys(source).forEach((key: string) => {
    const targetValue = target[key];
    const sourceValue = source[key];
    if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
      target[key] = uniquify(targetValue.concat(sourceValue));
    } else if (isObject(targetValue) && Array.isArray(sourceValue)) {
      target[key] = sourceValue;
    } else if (Array.isArray(targetValue) && isObject(sourceValue)) {
      target[key] = sourceValue;
    } else if (isObject(targetValue) && isObject(sourceValue)) {
      target[key] = deepMergeInnerDedupeArrays(
        Object.assign({}, targetValue),
        sourceValue
      );
    } else {
      target[key] = sourceValue;
    }
  });
  return target;
}

Another this is directory structure. You might not get the same output directory as your get with all files unless you use compilerOptions.rootDir. Typically:

  "compilerOptions": {
    "outDir": "dev-out",
    "rootDir": "./"
  },

Read about rootDir here.

Craig Hicks
  • 2,199
  • 20
  • 35