In a TS project I'd like the following to be blocked:
common
importing from folder projectA
projectB
importing from folder projectA
I'd like the following to be allowed:
projectA
importing from folder common
.I'm aware of References. However, as I understand, they require a build for type checking (If such a separation is made, one must build to create d.ts files first) which I'd rather avoid.
What options do I have? Is it possible to achieve simply via separate tsconfig files for each of those projects/folders?
Project references are a new feature in TypeScript 3.0 that allow you to structure your TypeScript programs into smaller pieces. By doing this, you can greatly improve build times, enforce logical separation between components, and organize your code in new and better ways.
More commonly, TypeScript modules say export myFunction in which case myFunction will be one of the properties on the exported object. Use import { myFunction } from "./myModule" to bring it in.
“Implementation Files” in this discussion means the TypeScript source files (.ts, .tsx) that will be compiled by the compiler into .js files. “Project Root” refers to the directory where the “tsconfig.json” locates.
Or use import boxen = require ("boxen");. const chalk = require('chalk'); console.log(chalk.blue('Hello world!')); Here, you can see that the thing it got from requiring boxen is being used as an object. Use import * as chalk from "chalk";. A TypeScript module can say export default myFunction to export just one thing.
I suggest to use a linter for that job, no need to adjust the build step or use Project References.
eslint-plugin-import
is a quite popular ESLint plugin, compatible to TS and can do what you want. After having configured typescript-eslint (if not already done), you can play around with these rules:
Let's try with following project structure:
| .eslintrc.js
| package.json
| tsconfig.json
\---src
+---common
| common.ts
|
+---projectA
| a.ts
|
\---projectB
b.ts
.eslintrc.js:
module.exports = {
extends: ["plugin:import/typescript"],
parser: "@typescript-eslint/parser",
parserOptions: {
sourceType: "module",
project: "./tsconfig.json",
},
plugins: ["@typescript-eslint", "import"],
rules: {
"import/no-restricted-paths": [
"error",
{
basePath: "./src",
zones: [
// disallow import from projectB in common
{ target: "./common", from: "./projectB" },
// disallow import from projectB in projectA
{ target: "./projectA", from: "./projectB" },
],
},
],
"import/no-relative-parent-imports": "error",
},
};
Each zone consists of the target path and a from path. The target is the path where the restricted imports should be applied. The from path defines the folder that is not allowed to be used in an import.
Looking into file ./src/common/common.ts
:
import { a } from "../projectA/a"; // works
// Error: Unexpected path "../projectB/b" imported in restricted zone.
import { b } from "../projectB/b";
The import/no-relative-parent-imports
rule also complains for both imports, like for a.ts
:
Relative imports from parent directories are not allowed. Please either pass what you're importing through at runtime (dependency injection), move
common.ts
to same directory as../projectA/a
or consider making../projectA/a
a package.
The third rule import/no-internal-modules
wasn't used, but I also list it here, as it can be very useful to restrict access to child folders/modules and emulate (at least) some kind of package internal modifier in TS.
But let's address some of your specific thoughts first:
Is it possible to achieve simply via separate tsconfig files for each of those projects/folders?
Yes, but it's not very flexible. You could isolate common by setting its rootDir
to .
. You would then get an '/path/to/projectA' is not under 'rootDir'
error if you tried to import projectA into common. But to be able to import common into projectA, its rootDir
would have to be more global, but then that would allow you to import projectB.
Not only that, according to the Project References documentation:
Previously, this structure was rather awkward to work with if you used a single tsconfig file:
- It was possible for the implementation files to import the test files
- It wasn’t possible to build
test
andsrc
at the same time without havingsrc
appear in the output folder name, which you probably don’t want- Changing just the internals in the implementation files required typechecking the tests again, even though this wouldn’t ever cause new errors
Changing just the tests required typechecking the implementation again, even if nothing changed
You could use multiple tsconfig files to solve some of those problems, but new ones would appear:
- There’s no built-in up-to-date checking, so you end up always running
tsc
twice- Invoking
tsc
twice incurs more startup time overheadtsc -w
can’t run on multiple config files at once
I'm aware of References. However, as I understand, they require a build for type checking (If such a separation is made, one must build to create d.ts files first) which I'd rather avoid.
What's the reason for this aversion?
If it's the upfront cost of building a fresh project clone, that will be more than made up for by the improved build times (see arguments for below). The benefits of the latter for developer productivity will far outweigh the costs of the former.
Ironically, the larger you concern about the upfront cost, the larger the benefit from the improved build times!
If it's that you want to be able to navigate a fresh clone in a type and linkage aware editor like VS Code or WebStorm without having to build, you can achieve this by checking the .d.ts
files into source control.
Here's what the docs say specifically:
Because dependent projects make use of
.d.ts
files that are built from their dependencies, you’ll either have to check in certain build outputs or build a project after cloning it before you can navigate the project in an editor without seeing spurious errors. We’re working on a behind-the-scenes .d.ts generation process that should be able to mitigate this, but for now we recommend informing developers that they should build after cloning.
From the docs:
you can greatly improve build times
A long-awaited feature is smart incremental builds for TypeScript projects. In 3.0 you can use the
--build
flag withtsc
. This is effectively a new entry point fortsc
that behaves more like a build orchestrator than a simple compiler.Running
tsc --build
(tsc -b
for short) will do the following:
- Find all referenced projects
- Detect if they are up-to-date
Build out-of-date projects in the correct order
Don’t worry about ordering the files you pass on the commandline -
tsc
will re-order them if needed so that dependencies are always built first.
enforce logical separation between components
organize your code in new and better ways.
There's some more useful benefits / features in the Project References doc.
src/tsconfig.json
Even if you have no code at the root, this tsconfig can be where
all the common settings go (the others will inherit from it), and
it will enable a simple tsc --build src
to build the whole
project (and with --force
to build it from scratch).
{
"compilerOptions": {
"outDir": ".", // prevents this tsconfig from compiling any files
// we want subprojects to inherit these options:
"target": "ES2019",
"module": "es2020",
"strict": true,
...
},
// building this project will build all of the following:
"references": [
{ "path": "./common" }
{ "path": "./projectA" }
{ "path": "./projectB" }
]
}
src/common/tsconfig.json
Because common has no references, imports are limited to targets within its directory and npm_modules
. You could even restrict the latter, I believe, by giving it its own package.json
.
{
"extends": "../tsconfig.json", //inherit from root tsconfig
"compilerOptions": {
"rootDir": ".",
"outDir": "../../build/common",
"composite": true
}
}
src/projectA/tsconfig.json
projectA can import common because of the declared reference.
{
"extends": "../tsconfig.json", //inherit from root tsconfig
"compilerOptions": {
"rootDir": ".",
"outDir": "../../build/projectA",
"composite": true
},
"references": [
{ "path": "../common" }
]
}
src/projectB/tsconfig.json
projectB can import common AND projectA because of the declared references.
{
"extends": "../tsconfig.json", //inherit from root tsconfig
"compilerOptions": {
"rootDir": ".",
"outDir": "../../build/projectB",
"composite": true
},
"references": [
{ "path": "../common" }
{ "path": "../projectA" }
]
}
These are just some examples. I use the abbreviate forms of tsc
switches below, e.g. -b
instead of --build
. All commands executed from the repo root.
tsc -b src
- builds the entire tree.
tsc -p src/projectA/
compiles just projectA.
tsc -b src/projectA/
builds projectA and any dependencies that are out of date.
tsc -b -w src
- build & watch the entire tree.
tsc -b --clean src
- delete the output for the entire tree.
tsc -b -f src
- force a rebuild of the entire tree.
Use the -d
or -dry
switch to get a preview of what tsc -b
will do.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With