Skip to main content

Module Resolution Mechanism_TypeScript Notes 14

Free2019-04-07#TypeScript#TypeScript Module Resolution#TypeScript模块查找#TypeScript模块解析规则#TypeScript exclude not working#TypeScript忽略

How exactly does import import a module?

Preface

[Modular mechanism](/articles/模块-typescript 笔记 13/) allows us to split code into multiple modules (files), and during compilation we need to know the exact type of dependent modules, so first we need to find it (establish mapping from module name to module file path)

Actually, in TypeScript, a module name may correspond to a .ts/.tsx or .d.ts file (if --allowJs is enabled, may also correspond to .js/.jsx files)

Basic idea is:

  1. First try to find the file corresponding to the module (.ts/.tsx)
  2. If not found, and it's not a relative module import (non-relative), try to find external module declaration (ambient module declaration), i.e., d.ts
  3. If still not found, error Cannot find module 'ModuleA'.

I. Relative vs Non-Relative Module Imports

Relative module imports (relative import) start with /, ./, or ../, for example:

import Entry from "./components/Entry";
import { DefaultHeaders } from "../constants/http";
import "/mod";

Note, relative module imports are not equivalent to relative path imports (e.g., /mod)

All other forms are non-relative module imports (non-relative import), for example:

import * as $ from "jquery";
import { Component } from "@angular/core";

Differences between the two:

  • Relative module imports: Search for modules relative to the file being imported, and won't be resolved as external module declarations. Used to import (can maintain relative positions at runtime) custom modules

  • Non-relative module imports: Search for modules relative to baseUrl or according to path mapping, may be resolved as external module declarations. Used to import external dependency modules

II. Module Resolution Strategies

Specifically, there are 2 module resolution strategies:

  • Classic: TypeScript's default resolution strategy, currently only used for backward compatibility

  • Node: Resolution strategy consistent with NodeJS module mechanism

These 2 strategies can be specified via --moduleResolution compilation option, defaults based on target module form (module === "AMD" or "System" or "ES6" ? "Classic" : "Node")

Classic

Under Classic strategy, relative module imports are resolved relative to the file being imported, for example:

// Source file /root/src/folder/A.ts
import { b } from "./moduleB"

Will try to find:

/root/src/folder/moduleB.ts
/root/src/folder/moduleB.d.ts

For non-relative module imports, start from the directory containing the file being imported and traverse up the directory tree, trying to find matching definition files, for example:

// Source file /root/src/folder/A.ts
import { b } from "moduleB"

Will try to find following files:

/root/src/folder/moduleB.ts
/root/src/folder/moduleB.d.ts
/root/src/moduleB.ts
/root/src/moduleB.d.ts
/root/moduleB.ts
/root/moduleB.d.ts
/moduleB.ts
/moduleB.d.ts

Node

NodeJS Module Resolution

In NodeJS, modules are imported via require, specific behavior of module resolution depends on whether parameter is relative path or non-relative path

Relative path handling strategy is quite simple, for:

// Source file /root/src/moduleA.js
var x = require("./moduleB");

Matching order is as follows:

  1. Try to match /root/src/moduleB.js

  2. Then try to match /root/src/moduleB/package.json, then find main module (e.g., if specified { "main": "lib/mainModule.js" }, import /root/src/moduleB/lib/mainModule.js)

  3. Otherwise try to match /root/src/moduleB/index.js, because index.js is implicitly treated as main module under that directory

P.S. Refer to NodeJS documentation: File Modules and Folders as Modules

Non-relative module imports search from node_modules (node_modules may be in same-level directory as current file, or in ancestor directory), NodeJS will search up through each node_modules, looking for module to import, for example:

// Source file /root/src/moduleA.js
var x = require("moduleB");

NodeJS will try to match in order:

/root/src/node_modules/moduleB.js
/root/src/node_modules/moduleB/package.json
/root/src/node_modules/moduleB/index.js

/root/node_modules/moduleB.js
/root/node_modules/moduleB/package.json
/root/node_modules/moduleB/index.js

/node_modules/moduleB.js
/node_modules/moduleB/package.json
/node_modules/moduleB/index.js

P.S. For package.json, actually load module pointed to by its main field

P.S. For more information about how NodeJS loads modules from node_modules, see Loading from node_modules Folders

TypeScript Emulates NodeJS Strategy

(When module resolution strategy is "Node") TypeScript also simulates NodeJS runtime module resolution mechanism, to find module definition files at compile time

Specifically, adds TypeScript source file suffixes to NodeJS module resolution logic, and also finds declaration files via types field in package.json (equivalent to simulating NodeJS's main field), for example:

// Source file /root/src/moduleA.ts
import { b } from "./moduleB"

Will try to match:

/root/src/moduleB.ts
/root/src/moduleB.tsx
/root/src/moduleB.d.ts
/root/src/moduleB/package.json
/root/src/moduleB/index.ts
/root/src/moduleB/index.tsx
/root/src/moduleB/index.d.ts

P.S. For package.json, TypeScript loads module pointed to by its types field

This process is very similar to NodeJS (first moduleB.js, then package.json, finally index.js), just with TypeScript source file suffixes

Similarly, non-relative module imports also follow NodeJS resolution logic, first find files, then find applicable folders:

// Source file /root/src/moduleA.ts
import { b } from "moduleB

Module search order is as follows:

/root/src/node_modules/moduleB.ts|tsx|d.ts
/root/src/node_modules/moduleB/package.json
/root/src/node_modules/@types/moduleB.d.ts
/root/src/node_modules/moduleB/index.ts|tsx|d.ts

/root/node_modules/moduleB.ts|tsx|d.ts
/root/node_modules/moduleB/package.json
/root/node_modules/@types/moduleB.d.ts
/root/node_modules/moduleB/index.ts|tsx|d.ts

/node_modules/moduleB.ts|tsx|d.ts
/node_modules/moduleB/package.json
/node_modules/@types/moduleB.d.ts
/node_modules/moduleB/index.ts|tsx|d.ts

Almost identical to NodeJS search logic, just additionally searches for d.ts declaration files from node_modules/@types

III. Additional Module Resolution Flags

During build, .ts is compiled to .js, and dependencies are copied from different source locations to same output location. Therefore, at runtime modules may have different names from source files, or compiled output module paths may not match corresponding source files

For these issues, TypeScript provides a series of flags to inform compiler about expected transformations on source paths, to generate final output

P.S. Note, compiler doesn't perform any transformations, only uses this information to guide resolution of module imports to their definition files

Base URL

baseUrl is common in applications following AMD module pattern, module source files can be in different directories, build scripts put them together. At runtime, these modules are "deployed" to single directory

In TypeScript, set baseUrl to inform compiler where to find modules, all non-relative module imports are relative to baseUrl, two ways to specify:

  • Command line argument --baseUrl (if specifying relative path, calculated based on current directory)

  • baseUrl field in tsconfig.json (if relative path, calculated based on directory where tsconfig.json is located)

Note, relative module imports are not affected by baseUrl, because always resolved relative to files importing them

Path Mapping

Some modules are not under baseUrl, e.g., jquery module at runtime may come from node_modules/jquery/dist/jquery.slim.min.js, at this point, module loader maps module names to runtime files via path mapping

TypeScript also supports similar mapping configuration (paths field in tsconfig.json), for example:

{
  "compilerOptions": {
    "baseUrl": ".", // This must be specified if "paths" is.
    "paths": {
      "jquery": ["node_modules/jquery/dist/jquery"] // This mapping is relative to "baseUrl"
    }
  }
}

Note, paths in paths are also relative to baseUrl, if baseUrl changes, paths must also change accordingly

Actually, also supports more complex mapping rules, e.g., multiple alternative locations, see Path mapping for details

rootDirs Specifies Virtual Directories

During compilation, sometimes need to integrate project source code from multiple directories into single output directory, equivalent to creating a "virtual" directory from a set of source directories

rootDirs can inform compiler about those "root" paths that compose the "virtual" directory, allowing compiler to resolve those relative module imports pointing to "virtual" directory, as if they've been integrated into same directory, for example:

src # Source code
└── views
    └── view1.ts (imports './template1')
    └── view2.ts

generated # Auto-generated template files
└── templates
        └── views
            └── template1.ts (imports './view2')

Assuming build tool will integrate them into same output directory (i.e., at runtime view1 and template1 are together), therefore can import via ./xxx方式。Can inform compiler of this relationship via rootDirs, list all source directories:

{
  "compilerOptions": {
    "rootDirs": [
      "src/views",
      "generated/templates/views"
    ]
  }
}

Thereafter, whenever encountering relative module imports pointing to rootDirs subdirectories, will try to find in each item of rootDirs

Actually, rootDirs is very flexible, array can contain any number of directory names, regardless of whether directories actually exist. This allows compiler to "catch" complex build/runtime features in type-safe way, such as conditional imports and project-specific loader plugins

For example, internationalization scenario, build tool automatically generates locale-specific bundles by inserting special path identifiers (e.g., #{locale}), e.g., mapping ./#{locale}/messages to concrete ./zh/messages, ./de/messages, etc. Also easily solved via rootDirs:

{
  "compilerOptions": {
    "rootDirs": [
      "src/zh",
      "src/de",
      "src/#{locale}"
    ]
  }
}

If local has zh language pack, at compile time will import files under src/zh, e.g., import messages from './#{locale}/messages will be resolved to import messages from './zh/messages'

IV. Tracing Resolution Process

Modules can reference files outside current directory, if wanting to locate module resolution related issues (e.g., can't find module, or found wrong one), it's not easy

At this point can enable --traceResolution option to trace compiler's internal module resolution process, for example:

$ tsc --traceResolution
# Imported module name and location
======== Resolving module './math-lib' from '/proj/src/index.ts'. ========
# Module resolution strategy
Explicitly specified module resolution kind: 'NodeJs'.
# Specific process
Loading module as file / folder, candidate module location '/proj/src/math-lib', target file type 'TypeScript'.
File '/proj/src/math-lib.ts' does not exist.
File '/proj/src/math-lib.tsx' does not exist.
File '/proj/src/math-lib.d.ts' exist - use it as a name resolution result.
# Final result
======== Module name './math-lib' was successfully resolved to '/proj/src/math-lib.d.ts'. ========

--noResolve

Normally, compiler tries to resolve all module imports before starting, each successfully resolved module import adds corresponding file to set of source files to be processed

--noResolve compilation option can prevent compiler from adding any files (except those passed via command line), at this point still tries to resolve files corresponding to modules, but no longer adds them, for example source file:

// Source file app.ts
import * as A from "moduleA"
import * as B from "moduleB"

tsc app.ts moduleA.ts --noResolve will correctly import moduleA, while moduleB will error not found (because --noResolve doesn't allow adding other files)

exclude

By default, directory where tsconfig.json is located is TypeScript project directory, if not specifying files or exclude, all files under that directory and its subdirectories will be added to compilation process. Can exclude certain files via exclude option (blacklist), or specify source files wanting to compile via files option (whitelist)

Additionally, during compilation when encountering imported modules, they will also be added, regardless of whether excluded or not. Therefore, to completely exclude a file at compile time, besides exclude itself, must also exclude all files referencing it

References

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment