5 lines of simple but complex JavaScript & TypeScript code
In JavaScript and TypeScript, it is possible to write just a couple of lines of "simple" code which is so complex under the hood, that I decided to write this entire blog post about it.
Let's start with the code itself, containing a total of 5 lines, spread over 3 files:
import { add } from './add'
const { A, B } = require('./constants')
console.log(`${A} + ${B} = ${add(A, B)}`)Can you spot the complexity? ☝️
In the index.ts file, we are importing the add function from the add.ts file.
But to access the A and B constants, a require statement is used.
This means we are trying to combine two different module systems: CommonJS and ES modules.
Let's figure out in this blog why this code is so complex and what happens under the hood. This blog will be full of code examples and integrated terminals. You can run the above code example yourself!
Since Node.js 24, it is possible to run TypeScript code directly without compiling it first. It does so by stripping the type annotations from the code. Instead of simply removing the types, they get replaced with whitespaces, so no source maps are required.
Apparently it is not able to find a file called add. That's correct: the file is called add.ts, not add.
Importing with or without extension (and whether it should be .js or .ts) is a whole topic on its own, but let's not get distracted ☁︎ Let's fix the issue instead!
import { add } from './add'
const { A, B } = require('./constants')
import { add } from './add.ts'
const { A, B } = require('./constants.ts')
console.log(`${A} + ${B} = ${add(A, B)}`)The error is clear: it is not possible to combine require and import statements in the same TypeScript file.
Module Type Detection
The error we got in our last run was quite clear:
So it should be quite straightforward to resolve it.
import { add } from './add.ts'
const { A, B } = require('./constants')
import { A, B } from './constants.ts'
console.log(`${A} + ${B} = ${add(A, B)}`)That works! 🎉
It makes sense to use import statements here, because the other files were ES Modules as well, because they're using export statements.
But what if you don't have a choice? Maybe the other file is written in CommonJS, and you can't change it because it's a dependency.
Let's therefore challenge that earlier error message. It said we were "in ES module scope".
How does it know that? Is that the default scope for TypeScript?
If that's true, then we should not be able to use require statements at all.
Let's try it out!
import { add } from './add.ts'
import { A, B } from './constants.ts'
const { add } = require('./add.ts')
const { default: constants } = require('./constants.ts')
console.log(`${A} + ${B} = ${add(A, B)}`)
console.log(`${constants.A} + ${constants.B} = ${add(constants.A, constants.B)}`)This worked as well 🤔 So now we cannot be in "ES module scope".
Note that, when you require() an ES module, it will return default as named export.
This is why we are using const { default: constants } = require('./constants.ts') here.
If we'd use the default export instead, it would have a different shape.
const { default: constants } = require('./constants.ts')
const defaultExportConstants = require('./constants.ts')
console.log(constants)
console.log(defaultExportConstants)Also note that we are not just requiring another CommonJS module here. We are actually requiring
an ES module, using both named and default exports! This is only supported since Node.js 22.0
using the --no-experimental-require-module flag, or since Node.js 22.12 without the flag.
Because of the impact of this change, it has also been backported without flag from Node.js
20.19.0.
Node.js decided itself to treat the index.ts file as CommonJS module, and the require()ed modules as ES modules.
This is the result of what I call Node.js' module type detection algorithm.
It is actually quite simple. It checks the following conditions in order for every file:
- Does the file have a
.mjs(or.mts) extension? -> ES module - Does the file have a
.cjs(or.cts) extension? -> CommonJS module - Does the closest
package.jsonfile have a"type": "module"field? -> ES module - Does the closest
package.jsonnot contain"type": "commonjs"and does the module contain ES Module syntax (import/export)? -> ES module - Otherwise -> CommonJS module
As long as it knows the module type of each file, Node.js is able to do its magic behind the scenes and load the files correctly. So we can chain both module types as deep as we want: ESM -> CJS -> ESM -> CJS -> ...
import { message } from './file2.cjs';
console.log(message);There is one caveat though: since the require() statement is synchronous, it is not possible to load ES Modules that use top-level await statements.
This will throw a clear error:
const { message } = require('./file2.mjs');
console.log(message);As the error suggests, using import() instead of require() will fix the issue.
Since import() is an asynchronous operation, we have to wait until the promise is resolved.
const { message } = require('./file2.mjs');
console.log(message);
import('./file2.mjs').then(({ message }) => {
console.log(message)
})Do you remember that we were initially trying to combine two different module systems in a single file?
The above code showcases a capability in Node.js related to that question!
In this file, we are combining a require statement with an import statement.
Not a regular import statement, but a dynamic one: import(), which is supported in both CommonJS and ES Modules.
The key difference is that import() is an asynchronous operation that runs at runtime,
whereas regular import statements are static and resolved at load time, before any code executes.
I'd love to dive deeper into this topic, maybe in another blog post.
But let's get back to our code example. Since it is now possible to require ES modules, is it also possible to import CommonJS modules?
const { add } = require('./add.ts')
const { A, B } = require('./constants.ts')
import { add } from './add.ts'
import constants from './constants.ts'
console.log(`${constants.A} + ${constants.B} = ${add(constants.A, constants.B)}`)That works using both module.exports and exports.property! 🎉
The fact that this is possible in the last few Node.js versions is great news,
because it means TypeScript authors can now
gradually migrate to the EcmaScript standard: ES modules.
For example, even though a dependency might be an ESM-only dependency,
it can still be used in CommonJS projects because you can require() it now.
Library authors therefore don't need to support both module systems anymore.
Since it's possible to import CommonJS and ES Modules, ánd it is possible to require CommonJS and ES Modules,
we can now basically mix and match ESM and CommonJS modules however we like.
So why are developers still unhappy about the current module system situation?
Well, the biggest practical blockers might have been removed in latest Node.js versions, so I expect there will be less complaints for projects that are using Node.js 22+. But several strategic reasons to go ESM still apply:
- It will work in both the browser and backend runtimes
- Better TypeScript support: requiring an ES Module will set the imported module's type to
any - CommonJS could be considered legacy, since it is not following the actual EcmaScript standard
- Top-level
awaitstatements are now possible - Better tree-shaking
- ...and more!
But if the above reasons are not convincing you to move to ESM, then let's dive into some confusing scenarios caused by other tools.
TypeScript Compiler
Usually a project is much more complex than what we've seen so far. So far, we have only used plain Node.js and TypeScript, without any compilation step. Real projects almost always use dependencies, a compilation step, a bundler, a testing framework, or a combination of all of these. TypeScript brings a lot of benefits, but it also introduces some complexity. Let's explore further.
Before we dive into the TypeScript Compiler, first a quick reminder of the initial code that we tried to run:
import { add } from './add'
const { A, B } = require('./constants')
console.log(`${A} + ${B} = ${add(A, B)}`)We know now that it is not possible to run files with both require and import statements using plain Node.js and TypeScript.
Let's see what happens when we first compile using the TypeScript compiler, and then run the resulting JavaScript code via Node.js:
The TypeScript compiler does two things here:
- It type-checks the code.
- It transpiles the code to JavaScript.
In the first step, the compiler errored because it does not know about the require
statement, since it is not part of the EcmaScript standard.
Even though the type checking step (step 1) errored, the transpilation from TypeScript to
JavaScript (step 2) will still happen. If you want to prevent this, you can use the
--noEmitOnError flag.
The type-check error states that we need to install type definitions for node.
We can do this by adding these to the tsconfig.json file. The compiler will then have access to
extra type definitions that are available in a Node.js environment.
In order for this to work, we also need to install the @types/node package and
set which files the TypeScript compiler should include
(note that we were previously specifying this in the tsc command itself).
{
"compilerOptions": {
"types": ["node"]
},
"include": ["**/*.ts"]
}What?? It just ran successfully, while we didn't change the TypeScript code at all 🤯
Besides running the code, we also ran the TypeScript compiler. There's no output from this, which seems a bit odd, but this means it ran the TypeScript compiler successfully. No news = good news! 🎉
The first time I noticed this behavior, I had no idea what was going on. But the knowledge we gained so far, will help us understand why it ran successfully. Let's check the output files created by the TypeScript compiler:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.add = void 0;
var add = function (a, b) { return a + b; };
exports.add = add;Do you notice what's happening in the index.js file?
The TypeScript compiler transpiled the import
statement to a require statement!
And we already know that using only require statements works fine,
we tested it earlier.
If ES Modules are the EcmaScript standard, then you might expect TypeScript to use that as the default module system. However, because of historical and practical reasons, TypeScript uses old defaults, which means the output JavaScript code will be written in CommonJS syntax. This can be confusing when people think they write ESM code, while in reality their output code is written in CommonJS syntax. If you want to know more about why this is the case, I recommend you to read about the module option (or let me know if you're interested in a deep-dive into that topic as well! 😉).
You might be thinking now: this is great! TypeScript fixed the last remaining compatibility issue!
However, this behavior is not following modern best practices and standards.
Unfortunately, TypeScript does not offer a solution to transpile the other way around:
giving it CommonJS syntax and get ES Modules in return.
This is not possible, because a require statement is a runtime operation.
A simple example to illustrate this is the following:
const x = condition ? require('a') : require('b')
It's not possible to only import one of the two modules using regular import statements.
Dynamic import() statements cannot be used either because they are asynchronous.
So unfortunately, if you want your code to be ESM only, you will need to migrate it yourself. Of course, you can use tools like AI, IDE tooling, or packages like ts2esm to help you migrate faster, but none of them are foolproof.
The only restriction is compiling CJS code to ESM. Of course, if your TypeScript code is written in ESM already, you can use modern tsconfig options to compile it to ESM JavaScript.
Default exports vs module.exports
In CommonJS, either the module.exports object is used to export the module, or
exports.property is used to export a single property.
This is very similar to ESM, where export default is used for default exports,
and export { property } is used for named exports.
However, there are some differences. One important one is that when importing a CommonJS module from an ES Module, any export other than the default export is not available while importing. Luckily, the error message is quite clear. Let's give it a try:
import { default as defaultExport, A, B } from './constants.js';
console.log(defaultExport, A, B);Note that defaultExport is available. It only started erroring on the named export A.
The error also proposes a solution.
import { default as defaultExport, A, B } from './constants.js';
import constants from './constants.js';
const { default: defaultExport, A, B } = constants;
console.log(defaultExport, A, B);The error message becomes way more confusing when running TypeScript directly instead of JavaScript (the content stays the same!):
import { default as defaultExport, A, B } from './constants.ts';
console.log(defaultExport, A, B);Let's park this knowledge for now, and get back to it later.
Module Resolution
Those with a sharp eye might have noticed that when we were transpiling code to CommonJS,
we did not include file extensions in the import statements. Let me show you once again:
import { A, B } from './constants';
console.log(A, B);This code will not run as ESM code:
However, as we have seen before, if we would first transpile it to CommonJS, it would run just fine:
What we are seeing here is the result of the module resolution algorithms.
CommonJS is more flexible than ESM in this regard.
If the specified file cannot be found, CommonJS will try to resolve the file
anyways, simply by trying out different paths that you might have intended.
This is possible, because require() is synchronous and runs in a backend environment,
where checking a few files is relatively quick.
You might have seen projects that import from directories directly. This works because of the
CommonJS module resolution algorithm. It will try to find an index.js file in that directory.
The code might be written in ESM, but TypeScript will most likely transpile it to CommonJS later.
I won't explain the exact CommonJS module resolution algorithm, but the 116 lines of pseudo code can be found here (and the ESM version can be found here).
The reason why ESM is more strict can be explained with a simple example.
ESM is the standard which is also used in the browser. Imagine import statements are allowed to be wrongly specified (for example, without extension), and the algorithm would have to make several calls to a server to find what the exact filename is. This would have a huge performance impact. So instead, ESM decided to be more strict, making sure only a single call is needed to get the correct file.
Importing require
Now that we have discussed different module resolution algorithms,
I'd like to show you a solution to combine import and require in the same ES Module file.
Node.js provides a utility function called createRequire which you can import from the module package.
It allows you to create a require function within an ES Module context.
import { createRequire } from 'module';
import { add } from './add.ts';
const require = createRequire(import.meta.url);
const { A, B } = require('./constants.cts');
console.log(`${A} + ${B} = ${add(A, B)}`);Since require is just a synchronous function, it is totally possible to create such a function within an ES Module context.
We are using the .cts extension here to specify that the constants file is written in CommonJS syntax.
It works! 🎉 This is a legitimate way to combine both module systems in a single file.
Unfortunately, also with this approach, there is a downside.
Since we are in ESM world, the module resolution algorithm used by this require function will be the ESM one.
This means that if we would try to import a CommonJS module without an extension (which would work fine in CommonJS), it will not work in this case.
Let's demonstrate this by placing the constants.ts file in a subdirectory with a package.json file that has "type": "commonjs".
import { createRequire } from 'module';
import { add } from './add.ts';
const require = createRequire(import.meta.url);
const { A, B } = require('./folder/constants');
console.log(`${A} + ${B} = ${add(A, B)}`);So make sure you keep this in mind when using this approach.
Confusing situations
Combining a tool like TypeScript with the difference between the two module resolution algorithms can cause serious confusion. Let me show you an example where we depend on an external package. The code of the external package is written in ESM:
export * from './constants';Because the target property in tsconfig.json is set to ES2024, the transpiled JavaScript code will be written in ESM syntax.
No output = success! ✅
export * from './constants';Let's assume everything works as expected, since the TypeScript type-checking ánd the compiler succeeded. Now if we would try to use the package in another project, we would run into a problem.
import { A, B } from 'external-package';
console.log(A, B);Now when developing locally, you will most likely be using a tool like ts-node or tsx to run your code.
Let's try it out:
Looking good, exactly as expected! We're not working on a real project here, but let's assume you have just finished building a new feature. You push this code, and your pipeline builds your app using the TypeScript compiler:
Once again, no output = success! ✅
Next step in the pipeline: deployment to production. This succeeds as well! ✅
But as soon as the deployment finishes, your production environment is down.. 🚨
What went wrong?
In scenarios where your production and local environments disalign, I'd recommend to try to locally run the app the same way as in production.
In this case, this would for example be using plain node instead of tsx:
The error indicates that the constants file is not found. As we've learned before,
the module resolution of ES Modules is more strict than that of CommonJS.
Since we are in ESM world in this case, the module resolution algorithm will not be able to find the constants file, because it's missing the extension.
But why did the TypeScript compiler transpile the external package successfully in the first place?
Well, remember that we set the target property in tsconfig.json to ES2024? This property decides which JS features need to be downleveled.
But it also impacts other tsconfig settings. For example, the module property uses this default:
CommonJS if target is ES5; ES6/ES2015 otherwise.
I'll not dive into the meaning of this property now, as its not relevant for this blog post.
What's important is that, based on the above default, module will be set to ES6.
This then again affects another tsconfig setting: moduleResolution. Let's see its default:
Node10 if module is CommonJS;
Node16 if module is Node16 or Node18;
NodeNext if module is NodeNext;
Bundler if module is Preserve;
Or classic otherwise.
Alright, so apparently moduleResolution gets set to classic when we set target to ES2024.
You would expect this value to work properly together with our target, since it's the default value.
But unfortunately, this is not the case. In fact, TypeScript states the following:
'classic' was used in TypeScript before the release of 1.6. classic should not be used.
Great default.. 🤦♂️ Let's assume this is the case because of historical reasons.
But defaults exist to be changed! According to the description of moduleResolution, we should use node16 or nodenext instead:
node16 or nodenext for modern versions of Node.js. Node.js v12 and later supports both ECMAScript imports and CommonJS require,
which resolve using different algorithms. These moduleResolution values, when combined with the corresponding module values,
picks the right algorithm for each resolution based on whether Node.js will see an import or require in the output JavaScript code.
Alright, so using this value, TypeScript will figure out whether the file is an ESM or CommonJS module. Based on that, it will use the correct module resolution algorithm.
Also please note it says: when combined with the corresponding module values.
So when we change moduleResolution, we'll need to change module to the same value.
Let's change both of them to nodenext in that case:
{
"compilerOptions": {
"target": "ES2024",
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["**/*.ts"]
}Now we'll get an immediate TypeScript error in our IDE when trying to import without .js extension:
Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './constants.js'?ts(2835)
Running the TypeScript compiler will give us the same error:
Changing the import (and re-running tsc) fixes the error and will make our code run again:
export * from './constants';
export * from './constants.js';Issue resolved! 🎉
Because of reasons like these, it is always a good practice to health check your app before replacing it with the current production version. You could run your app in your pipeline, or deploy the new environment side-by-side with the current production environment, and wait with swapping environments until the health check is successful. This would have prevented the deployment to production in this case.
Conclusion
Before we wrap up, let's answer the initial question:
Is it possible to use both import and require in the same file?
Yes! Whether you're using createRequire, TypeScript compilation, or tools like bundlers,
it is possible to use both import and require in the same file.
But there are more important lessons we've learned today.
You now understand the differences between the two module systems. I hope you now agree that it is a good idea to migrate all your code to native ESM. Because let's be honest, issues like the ones we discussed are hard to prevent/notice, and are unfortunately not resolved by Node.js having much better ESM/CJS compatibility nowadays. So I don't expect the complaints about the different module systems to be gone anytime soon.
But hey, since you can now require() ES Modules in all supported Node.js versions,
there are less excuses than ever not to migrate!
I think we've covered enough for this blog post.
By the way, this is also my very first blog post, so hopefully you enjoyed it. And if you learned something new, sending me a message or sharing this blog post with your friends is the best way to support me 🙏
Thanks for sticking around all the way to the end!
References
Modules - ESM/CJS Interoperability