24

In a TS project I'd like the following to be blocked:

  • A file from folder common importing from folder projectA
  • A file from folder projectB importing from folder projectA

I'd like the following to be allowed:

  • A file from folder 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?

1
  • How strong is your reason to avoid using Project References because of the build dependency? See the counterpoints in my answer
    – Inigo
    Commented Apr 28, 2020 at 1:17

2 Answers 2

19
+100

TLDR; You really should just use References. It is exactly what they are for.

But let's address some of your specific thoughts first:

  1. 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 and src at the same time without having src 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 overhead
    • tsc -w can’t run on multiple config files at once
  2. 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.

The argument for Project References

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 --buildflag with tsc. This is effectively a new entry point for tsc 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.

Example setup

  • 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" }
        ]
      }
      

Builds

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.

4
  • Thanks! If I change a type of an exported param I need to run "tsc -p src/common" for d.ts files to be generated, and typing of imports from common to be correct right?
    – Ben Carp
    Commented Apr 29, 2020 at 19:31
  • 1
    @BenCarp Yes, exactly. Or if you change common while working on projectA, tsc -b src/projectA/ will recompile the changed items in common and the items in projectA that are affected. And before committing your changes you can tsc -b src to make sure everything builds, or leave that to your continuous integration system. I added sample build commands to my answer and fixed the sample tsconfigs.
    – Inigo
    Commented Apr 30, 2020 at 7:49
  • Inigo, this specific project is actually build by Webpack and compiled babel. So I guess it makes the build commands irrelevant for this project, I see there is a watch command. Will it allow type changes to common to be compiled automatically, and have effect on the projects?
    – Ben Carp
    Commented May 2, 2020 at 7:55
  • The tsc --build commands are not irrelevant for exactly the reason you give, and as I already explained above "any dependencies that are out of date". It's easy enough to setup a simple project to play around with and answer all your questions. I don't know how it works with Webpack and Babel.
    – Inigo
    Commented May 2, 2020 at 10:09
15

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.

10
  • This is very interesting and I'll try it tomorrow. However, A Typescript solution (If there is such a solution without additional builds) should be superior as it will only show available/permitted imports for autocomplete.
    – Ben Carp
    Commented Apr 27, 2020 at 19:21
  • >"Relative imports from parent directories are not allowed." If I have a file ProjectA/innerFolder/innerFolder/file.ts which tries to import from projectA/consts.ts will it be blocked?
    – Ben Carp
    Commented Apr 27, 2020 at 19:28
  • Hm I doubt, there is a direct TS feature, that disallows certain module import patterns (or at least I don't know it). If you need stronger encapsulation, you typically create another npm package. For autocomplete: Maybe multiple configs, which limit inputs, but that will have its own organizing cost and won't prevent module resolution/importing them.
    – ford04
    Commented Apr 27, 2020 at 19:36
  • yes, eslint will error on everything which has ../ in the import path
    – ford04
    Commented Apr 27, 2020 at 19:36
  • 2
    @Inigo, I believe what you wrote is correct. However, if you do consider the linter as an essential part of your tool-chain then 1)All errors show in the editor(depending on a plugin) 2) The linter runs before every build (depending on a script)
    – Ben Carp
    Commented Apr 28, 2020 at 3:51

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.