How to Correctly Use TypeScript Module Import Syntax and Settings in Various Circumstances

Bing Ren
JavaScript in Plain English
7 min readJun 24, 2021

--

Photo by Ryoji Iwata @ryoji__iwata

Importing modules into TypeScript code then compiling and running the code with Node.js is not always as simple as it sounds. In fact, the outcome depends on several factors playing out together. Under certain circumstances, it’s not easy to set all these factors correctly to make them work.

If you are struggling to import and use a new ESM module in a CommonJS package, or you are confused by error messages such as “(module) can only be default-imported using the ‘esModuleInterop’ flag”, “Import assignment cannot be used when targeting ECMAScript modules”, “require is not defined in ES module scope, you can use import instead”, then read on.

I did a project that tested each of the full combinations of the various settings of these factors, to find the straightforward and proven answer for the question, “When to use this or that setting?” The answer I was looking for had to be better than “it depends”.

The Players and Factors that Affect the Results

We should first understand the three players in this game: the TypeScript compiler tsc, the Javascript runtime Node.js, and the modules we want to import into our code. In this process, each player has some settings or a property that affects the overall result.

We write our code in TypeScript. Our code includes import statements for importing other modules that we use. We use tsc to type check and compile our Typescript code, including the import statements, into Javascript. During this process, the syntax of the import statements (in TypeScript), and the tsc options “module”, “allowSyntheticDefaultImports” and “esModuleInterop”, affect the type checking and the resulting JavaScript — including the result import statements in the JavaScript files.

We put the resulting JavaScript files in a package with a “package.json” file in the package’s root, and use Node.js to run them. At runtime, Node.js treats our JavaScript files as either CommonJS modules or ES Modules (but not both) and provides the JavaScript code with either the CommonJS or ES Module system accordingly. Node.js determines the type of our modules according to the “type” field in the “package.json”. Read here for more details on this.

These two module systems provide very different syntaxes and implementations. JavaScript with CommonJS can use “require()” to import other CommonJS modules, but can only use dynamic import “import()” to import ES Modules. JavaScript using ES Module can use both the “import” statement (e.g. import moment from “moment”) and dynamic import to import both CommonJS and ES Modules. If you are not aware of the basics of the two module systems and their major differences, stop here, do some study of the two module systems, before continuing.

To recap briefly, the factors and how they affect the outcome:

  • the syntax of the import statements in the TypeScript code and the compiler (tsc) options (“module” and “esModuleInterop”)— together affect the type checking and determine the import statements in the resulting JavaScript code,
  • “type” field in the “package.json” — determines the package type and which module system Node.js provides for the JavaScript code in it, and
  • the type (CommonJS or ES Module) of the module being imported — together with the other factors above determines the outcome.

The Experiment

With all these factors interplay together to affect the outcome, the correct combination to use under a certain circumstance is not always obvious. I developed a script to test out the complete combination of these factors and the eventual outcome of each combination of these factors — whether the TypeScript code can be both compiled and executed successfully under each of the combinations. The project can be found here.

In the experiment, the factors are defined as:

(1) PKG-TYPE: the “type” field in “package.json”, determining the type of the package, takes the values of “commonjs” (for CommonJS) or “module” (for ES Module)

(2) TARGET: the module to be imported. I chose “moment” as representative of CommonJS modules and “p-map” as representative of ES Modules. In the end, I added “mixed”, in which case I attempted to import both “moment” and “p-map” and use them together

(3) MODULE: the “module” option in tsc settings (set in tsconfig.json or through command-line argument), takes the values of “commonjs” (instructing tsc to emit CommonJS import statements, e.g. “require()”) or “es2020”. “es2015”, “es2020” or higher settings instruct tsc to emit ES Module import statements. “es2015” produces the same result as “es2020”, however, this option does not support dynamic import. See tsconfig.json reference for more about this option.

(4) CASE: the test cases in which different Typescript import syntaxes are used. For packages “moment” (representing CommonJS packages) and “p-map” (representing ES Module packages) this includes:

  • “default-import”: e.g. import moment from “moment”
  • “namespace-import”: e.g. import * as moment from “moment”
  • “import-equal”: the import=require() syntax, e.g. import moment = require(“moment”)
  • “dynamic-import”: e.g. import(“moment”).then() or await import("moment")
  • “old-require”: the old CommonJS require() syntax, e.g. const moduleMoment = require(“moment”)

For importing mixed modules (TARGET=mixing), two test cases are provided, to showcase and prove the correct syntax and settings for using mixed packages in either CommonJS or ES Module package.

(5) esModuleInterop: the “esModuleInterop” option in tsc settings fixes some mismatch issues when treating CommonJS modules similar to ES Modules. Enabling esModuleInterop will also enable allowSyntheticDefaultImports. Takes the values between “True” and “False”.

The script compiles each test case — a .ts file (“CASE”) that imports different modules (“TARGET”) — into Javascript using different tsc options (“MODULE” and “esModuleInterop”), and put it in different packages (“PKG-TYPE”). If the compilation is a success, the script executes the Javascript with Node.js to check the eventual result.

The Raw Result

The experiment produces a report that looks like below, and the complete report can be found here. Each line of the report indicates the result under one combination.

The Straightforward Guide

With the full combinations tested, we can answer the question: what import syntax and options shall we use to import a module under a certain circumstance? Let’s start with simple cases.

When a CommonJS module imports another CommonJS module

When “type” in our “package.json” is “commonjs” or absent, and we are importing another CommonJS module, set the “module” option in tsconfig.json to “commonjs”. Then, we can use any of the “default import”, “namespace import” or “import = require()” syntax. Note that, however, sometimes only one of the “namespace import” and “default import” syntax would work, depending on the “esModuleInterop” setting.

We can also use the old “require()” syntax. However, this syntax loses the typing (the module is imported as “any”). Or we can use dynamic import if necessary. However when possible the static import is preferable, as it benefits more readily from static analysis tools and tree shaking.

When an ES Module imports another ES Module

When “type” in our “package.json” is “module” and we are importing another ES Module, set the “module” option in tsconfig.json to “es2020”. Then, we can use either the “default import” or “namespace import” syntax. “esModuleInterop” doesn’t matter.

Again, we can use dynamic import, but only when necessary. Static import is preferable for benefits from static analysis tools and tree shaking.

When an ES Module imports a CommonJS module

When “type” in our “package.json” is “module” and we are importing a CommonJS module or mixed types of modules, set the “module” option in tsconfig.json to “es2020” and “esModuleInterop” to “True”. Then, we can use either the “default import” syntax or dynamic import to import CommonJS modules, and “default import”, “namespace import” or dynamic import to import ES modules. Again, use dynamic import only when necessary.

The code looks like below:

When a CommonJS module imports an ES Module

Now comes the most tricky part. When “type” in our “package.json” is “commonjs” or absent, and we are importing an ES Module or mixed types of modules, set the “module” option in tsconfig.json to “es2020” (!!) and “esModuleInterop” to “True”. Then, use dynamic import to import the ES Module, and use either the old “require()” syntax or dynamic import to import CommonJS modules. The code looks like below:

Setting tsc’s “module” option to “es2020” is strange as we are working on a CommonJS module. The reason is, when Node.js is using the CommonJS module system, the only way to import an ES Module is to use dynamic import.

However, at the time of writing, when the “module” option is set to “commonjs”, tsc transpiles dynamic import to “require()” (See this open issue for more details). Therefore we have no choice but to use “es2020”, even we are building a CommonJS package. Then we can use dynamic import to import both CommonJS and ES Module. And we can also use the old require() syntax (won’t be transpiled) to import CommonJS modules. Other than the above, the import would fail either at compile time or runtime.

Conclusion

Importing a CommonJS module from CommonJS module or importing an ES Module from ES Modules is not difficult. However, it becomes tricky when we mix them up. This article provides straightforward guides on how to set up your project under such circumstances to make it work.

More content at plainenglish.io

--

--