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!

$ node index.ts

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)}`)
$ node index.ts

The error is clear: it is not possible to combine require and import statements in the same TypeScript file.

The error we got in our last run was quite clear:

ReferenceError: require is not defined in ES module scope, you can use import instead

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)}`)
$ node index.ts

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)}`)
$ node index.ts

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)
$ node index.ts

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:

  1. Does the file have a .mjs (or .mts) extension? -> ES module
  2. Does the file have a .cjs (or .cts) extension? -> CommonJS module
  3. Does the closest package.json file have a "type": "module" field? -> ES module
  4. Does the closest package.json not contain "type": "commonjs" and does the module contain ES Module syntax (import/export)? -> ES module
  5. 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);
$ node file1.mjs

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);
$ node file1.cjs

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)
})
$ node file1.cjs

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)}`)
$ node index.ts

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:

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.

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:

$ tsc index.ts add.ts constants.ts && node index.js

The TypeScript compiler does two things here:

  1. It type-checks the code.
  2. 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"]
}
$ tsc && node index.js

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:

add.js
add.ts
constants.js
constants.ts
index.js
index.ts
package.json
file_type_tsconfig_officialtsconfig.json
"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.

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);
$ node index.js

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);
$ node index.js

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);
$ node index.ts

Let's park this knowledge for now, and get back to it later.

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:

$ node index.ts

However, as we have seen before, if we would first transpile it to CommonJS, it would run just fine:

$ tsc index.ts constants.ts && node index.js

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.

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.

$ node index.ts

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

index.ts
add.ts
folder
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)}`);
$ node index.ts

So make sure you keep this in mind when using this approach.

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:

index.ts
constants.ts
package.json
file_type_tsconfig_officialtsconfig.json
export * from './constants';

Because the target property in tsconfig.json is set to ES2024, the transpiled JavaScript code will be written in ESM syntax.

$ tsc

No output = success! ✅

index.ts
index.js
constants.ts
constants.js
package.json
file_type_tsconfig_officialtsconfig.json
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.

index.ts
package.json
external-package
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:

$ npx tsx index.ts

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:

$ tsc index.ts

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:

$ node index.js

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:

index.ts
package.json
external-package
{
    "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:

$ cd external-package && tsc

Changing the import (and re-running tsc) fixes the error and will make our code run again:

index.ts
package.json
external-package
export * from './constants';
export * from './constants.js';
$ node index.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.

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!

The module compiler option

Modules - ESM/CJS Interoperability

Loading ECMAScript modules using require

import() expressions

Move on to ESM only