DEV Community

Cover image for TypeScript 5.0: new mode bundler & ESM
Ayc0
Ayc0

Posted on

TypeScript 5.0: new mode bundler & ESM

In TypeScript 5.0, 2 new features were released:

  • moduleResolution: bundler
  • allowImportingTsExtensions

(see release note)

Let’s dive into what these allow us to solve.

Problem

Introduction

Let’s take this piece of code:

// INPUT
// foo.ts
export const foo = 'foo';

// bar.ts
import { foo } from './foo';

export const bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

If we want to transpile this plain JavaScript, with module: CommonJS set in our tsconfig.json, we’d get something like:

// OUTPUT
// foo.js
exports.foo = 'foo';

// bar.js
const { foo } = require('./foo');

exports.bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

But now you could tell me:

“Wait, I thought that JS now supports import/export? Also, isn’t this require/exports only for Node.js?”

Yes, you’re totally right! import/export are part of ESM (ECMAScript Modules). And require/exports are indeed only valid in Node.js.

Let’s try to use ESM!

ESM

You can enable this via module: es6 / es2020 / esnext / node16 in your tsconfig.json.
With the same input, we’d get:

// OUTPUT
// foo.js
export const foo = 'foo';

// bar.js
import { foo } from './foo';

export const bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

“Wait, what’s the catch here? This seems to be valid!”

In the ECMAScript spec, they mention that imports need to have a file extension, so import { foo } from './foo'; is not valid (see in Node.js’s doc).

Which extension should we pick?

Okay, let’s say I want to build for ESM, what extension should I write in my source code?
Let’s try a different code samples:

.ts

With a .ts extension:

// INPUT
// foo.ts
export const foo = 'foo';

// bar.ts
import { foo } from './foo.ts';

export const bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

we’d get this code in JS:

// OUTPUT
// foo.js
export const foo = 'foo';

// bar.js
import { foo } from './foo.ts';

export const bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

You can see that this cannot work: foo.ts doesn’t exist in the generated code as the generated file is named foo.js

.js

Okay, so what about with a .js file then?

// INPUT
// foo.ts
export const foo = 'foo';

// bar.ts
import { foo } from './foo.js';

export const bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

This would generate:

// OUTPUT
// foo.js
export const foo = 'foo';

// bar.js
import { foo } from './foo.js';

export const bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

This indeed works!
Hoorah! We have some working code with TypeScript & ESM 🎉
This is the official way of supporting ESM in TS files: https://www.typescriptlang.org/docs/handbook/esm-node.html.

The only issue is that we have to write down foo.js in the source TS file, which is weird 😕 (as the file doesn’t exist during dev time).

Why are extensions required in ESM?

Due to how the web works, it has to work with files that are fully pre-determined, otherwise when writing import './foo', if it implemented the same logic as node, we’d have to download: foo.js, foo.cjs, foo.mjs, foo/index.js, etc.
So for browsers, it makes more sense to treat the import as the reference.

Why isn’t the transform of the extension automatically done by TypeScript?

TypeScript never rewrites module specifiers in its JavaScript emit.

I don’t have the exact reasons why they don’t do it, but to me this would counterproductive as there are more differences between CommonJS & ESM (see TS v4.7 release note).
So enforcing the use of the extensions, is a great way to tell people that we are in ESM, and also is closer to the underlying generated JS logic.

Summary of the issue

To support ESM, we need to:

  • Change all imports use .js, .cjs, or .mjs -> large update,
  • The new imports don’t match the source files,
  • Tools like webpack/esbuild/deno do support .ts files in paths, so why is this an issue?

Solution: allowImportingTsExtensions

In TypeScript 5.0, a new option was added: allowImportingTsExtensions, which allows users to use .ts in imports!

The only requirement with it, is that we cannot emit code anymore (as this wouldn’t produce any valid JavaScript code).

So either noEmit: true needs to be set, or emitDeclarationOnly: true (as importing .ts files is allowed in .d.ts files).

We can now write the following (useful for Deno for instance):

// INPUT
// foo.ts
export const foo = 'foo';

// bar.ts
import { foo } from './foo.ts';

export const bar = `${foo} sets a new bar`;
Enter fullscreen mode Exit fullscreen mode

Solution: moduleResolution: bundler

Enabling the .ts extension is already a bonus, but why should we write any extension at all when the code is bundled anyway by Webpack, Vite, esbuild, Parcel, rollup, swc?

If your code is bundled, there is now a new option that you can use starting with TypeScript 5.0: moduleResolution: bundler (see PR that added it).

This:

  • tells TypeScript that you’re code will be bundled by another tool, and thus to loosen the rules with imports (can have no extension, or use .ts extensions)
  • requires to use module set to es2015 or later (which enables parsing exports in package.json and other changes)

Note: Using es2015 or later will enable new rules in TS (e.g.: like disabling require), so it won’t be a no-op.

Conclusion

Module resolution in TypeScript is complex, and has evolved over the years. TypeScript 5.0 added 2 new tools to allow adapting the tool in more contexts than before. But they all come with requirements.

Top comments (3)

Collapse
 
december1981 profile image
Stephen Brown • Edited

This is a very nice description, thanks.

Doesn't change the fact that modules are a stinking mess with javascript, and the weirdness with having to specify the .js extension in ts code is a reflection of that.

The decision by the nodejs people when they decided to natively support esm, to have to explicitly specify the extension for imports - as opposed to allowing it to resolve the file with some scheme like they did with commonjs: look for a file with ".js" if the import path does not explicitly specify an extension - makes no sense to me.

Their comment: "This behavior matches how import behaves in browser environments, assuming a typically configured server." is disingenuous, to say the least. Node isn't a browser environment, for crying out loud. It also makes it harder to use libraries exported from tsc, compiled to "module" setting es2020/esnext, etc - where there is no explicit import extension in the js output - understood in webpack environments where this file resolution is done by the transpiler when building the bundle, but would fall over if run directly in node (eg a cli tool running in the webpack application workspace which might use ts-node).

Collapse
 
designbyonyx profile image
Ryan Wheale • Edited

The javascript ecosystem is trying to create a module specification that works in virtually any environment. There are numerous benefits to this - mostly centering around interoperability and consistency between different environments.

The decision by the nodejs people ... to have to explicitly specify the extension for imports ... makes no sense to me.

It wasn't node.js that added the requirement for the file extension - it was the EcmaScript specification itself... which affects all JS. Node isn't copying the browser... they are implementing the spec. The old CommonJS algorithm for finding files was terribly inefficient and was largely a mistake (the creator of node admitted this mistake: youtu.be/M3BM9TB-8yA?t=835). By being explicit, JS runtimes don't have to look for the file... which is much faster and more explicit and affords a lot of other efficiencies. Adding typescript even compounds the problem: typescriptlang.org/docs/handbook/m...

As for the node REPL, it sucks it doesn't support ESM yet. I'm sure it will change soon. I generally use tsx to do most of my node REPL stuff - it's super fast and works with any flavor of JS or TS you are using.

If you are still writing code in CommonJS, I highly suggest you upgrade. You will be helping the community to move past CommonJS, and it is safe to do today. I just upgraded a large 6-year old codebase at a company who is resistant to change... and it went fairly smoothly once I got over the learning curve.