Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Preventing inappropriate imports and enforcing project hierarchy in Typescript

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?

like image 339
Ben Carp Avatar asked Apr 16 '20 02:04

Ben Carp


People also ask

What are project references in typescript?

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.

How to import MyFunction in a typescript module?

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.

What are implementation files in typescript?

“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.

How do I export just one thing in typescript?

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.


2 Answers

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:

  • import/no-restricted-paths - Restrict which files can be imported in a given folder
  • import/no-relative-parent-imports - Prevent imports to folders in relative parent paths
  • import/no-internal-modules - Prevent importing the submodules of other modules

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.

like image 192
ford04 Avatar answered Oct 19 '22 17:10

ford04


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.

like image 36
Inigo Avatar answered Oct 19 '22 18:10

Inigo