1: ESM, CJS and … - Interlude: Should we use Native ESM? Yes, but … - Chapter 2: TypeScript, ESM and "in-place" runtimes - Interlude: Who needs to resolve modules, except runtime and bundler? - Chapter 3: Import path alias / path mapping today
somewhat consistent way that works well. - If it's an application, it should work well at the various runtimes you want to use. - If it is a library, it must be resolvable by the application that wants to use it, and it must be resolvable and work well at multiple target runtimes. - If it is a peripheral toolkit that needs to interpret module resolution, it should work well and be good in a good configuration.
“node:*”, “https://*” - Runtime issues - module.exports can not treat with named import - Lack of __esModule in Native ESM, default import/export issue - Dual package hazard - TypeScript issues - See arethetypeswrong/arethetypeswrong.github.io
your artifact? - Browser? Server? Edge? In-app runtime? - How does runtime load it? - Just takes “./src” - Runtime resolves it - Transpiled into “./dist” and keep the file tree - Runtime resolves it (and maybe transpilers also do) - Single bundle - Bundler resolves it - Multiple chunks - Bundler resolves it (and maybe runtimes also do for chunks)
を使います - The name "CommonJS" can be used to refer to the original module resolution behavior of Node.js - Algorithm: https://nodejs.org/api/modules.html#all-together
Nearest parent package.json “type” field - type: module → *.js file is ESM - type: commonjs(default) → *.js file is CJS - File extensions - *.cjs file is CJS - *.mjs file is ESM - Syntax detection - A file consists only of ESM syntax, the file is ESM
map to different paths depending on some conditions” - import: loaded via import - require: loaded via require - module-sync: no top-level-await ESM file https://nodejs.org/api/packages.html#conditional-exports
- `import d, { n1, n2 } from "./some/module";` - using Import Statement - without file extension - 🤷 2 patterns here: - Runs as CJS (maybe). Syntax only ESM. The code should be transpiled internally or explicitly. - “Fake ESM” / “Faux ESM” - Runs as ESM. But they resolved like the CJS on Node.js - “Sloppy import” in Deno - Native ESM can be written like this, but let's talk about that later
with create-${something}-app like command: - Next.js: Fake ESM - With `type: module` it works as Sloppy ESM. webpack + fullySpecified: false - Vite ( Nuxt.js, Remix, Astro, … ): Sloppy ESM - Vite is based on Rollup. It works as Sloppy ESM or higher - Angular: Fake ESM - With `type: module` it works as Sloppy ESM or higher because of esbuild - Expo: Fake ESM - Metro bundler supports CommonJS only today.
use Native ESM. Or Sloppy ESM is fine - Node.js can accept Sloppy ESM with custom loader - Sloppy ESM is fine because It’s enough that your runtime can handle them CJS Fake ESM Sloppy ESM Native ESM Actually runs as CJS CJS ESM ESM import or require require import import import Extension guessing ✅ ✅ ✅ ❌ Folders as Modules ✅ ✅ ✅ ⚠
use Native ESM. Or Sloppy ESM is fine - Node.js can accept Sloppy ESM with custom loader - Sloppy ESM is fine because It’s enough that your runtime can handle them - Tree shaking / static analysis friendly - Better development experience - No bundle in development (Native ESM or transpile time resolution, prebundle) - Reduce dependencies - If libraries support only Native ESM, apps are forced to be Native ESM? - This is relaxed by require(esm) landed - My app is based on Expo, react-native! - Keep going with Fake ESM
use Native ESM - To achieve broadest interoperability, yes. - Maybe Sloppy ESM would be OK in development, but the results are harder to predict. - Should we also provide CJS version?: Yes, but relaxed by require(esm) - For “Fake ESM” codebases, truly yes - Watch an adaption rate of Node.js, require(esm) supported version CJS Fake ESM Sloppy ESM Native ESM Actually runs as CJS CJS ESM ESM import or require require import import import Extension guessing ✅ ✅ ✅ ❌ Folders as Modules ✅ ✅ ✅ ⚠
use Native ESM - To achieve broadest interoperability, yes. - Maybe Sloppy ESM would be OK in development, but the results are harder to predict. - Should we also provide CJS version?: Yes, but relaxed by require(esm) - For “Fake ESM” codebases, truly yes - Watch an adaption rate of Node.js, require(esm) supported version - It’s hard to maintain “exports” field properly - Use package bundler like tshy. 📝require(esm) support is not yet - My library is for react-native! - Keep going with Fake ESM
Use `import m from “./foo.js”` for foo.ts file. So the answer is invalid at this time. 🔗 - TypeScript’s principle: “preserve JavaScript as written” - To keep Single-File Transpilability, they don't want to rely on context outside the file 🔗 - TypeScript 5.0 `—moduleResolution bundler` and correspondences - TypeScript “in-place” runtimes support, like Bun - We can write `import m from “./foo.ts”` for foo.ts file, under `--allowImportingTsExtensions`. - Instead, it’s forced to set `--noEmit` or `--emitDeclarationOnly` - Node.js v23.0.0 Built-in TypeScript support - Importing TypeScript file requires “extensions everywhere” style evenly CJS for compatibility. - TypeScript 5.7 `—rewriteRelativeImportExtensions` - For users who develop with TypeScript and publish / execute as JavaScript - Should be able to write *.ts extension at development time - But it should be written as "*.js" extension for final artifacts
can safely set `--allowImportingTsExtensions`: Yes, it’s valid. - This requires `--noEmit` or `--emitDeclarationOnly` - This means 2 patterns - If you are pre-processing all files using a bundler - Most web frontend projects would be this - Often pre-bundled with a server as well - When running at "in-place" runtime
"in-place" runtime and the end product is a set of unbundled JS files - And when the conversion is done by tsc - A tool used only by those who fully understand it - Only works with relative paths and extensions of the "ts" series - directory "foo.ts" also becomes "foo.js - Does not work under import path aliases https://x.com/SeaRyanC/status/1840922680725553237
with TypeScript, the *.ts extension is valid. - If you can safely enable `--allowImportingTsExtensions - Some environments have emerged that can run TypeScript directly. - In the case of Node.js, even if it is CommonJS, you have to write the extension - ⚠ is not stable yet, so be careful in the future. - If you want to end up using *.js, there are more options. - However, import path aliases don't currently work in this case. - There are many edge cases. -
TypeScript - ESLint + eslint-plugin-import and forks - Jest, Vitest(same as Vite) - Storybook(webpack or Vite) - Other document generation tools, etc. - Most tools since the CJS days can also resolve ESM import paths. - It’s just like a subset without Extension guessing / Folders as modules
or plugin - Setup static analyzers - TypeScript: tsconfig.json “paths” field - ESLint + eslint-plugin-import(-x): install suitable `eslint-import-resolver-${resolverName}` - Setup a testing tool - Jest: “moduleNameMapper” or “resolver” option - Vitest: same as Vite We need to maintain them all coming out the same. 🤯 Configuring the "import path alias" is a boring task
to treat tsconfig.json (or jsconfig.json) as Single Source of Truth - Setup bundlers - Vite: vite-tsconfig-paths, @rollup/plugin-typescript - esbuild: built-in tsconfig/jsconfig support - webpack: tsconfig-paths-webpack-plugin - Setup static analyzers - TypeScript: tsconfig.json “paths” field - ESLint + eslint-plugin-import(-x): eslint-import-resolver-typescript - Setup a testing tool - Jest: ts-jest-resolver - Vitest: same as Vite - Setup for runtime - tsc-alias, if you don’t use bundler
enough Mapping expressiveness classificaion - Exact match replacement: "#foo" -> "./src/foo.js" - An alias of an external module is covered in a different way - Prefix match replacement: "#components/*" -> "./src/components/*" - All of “module resolver” support them - Single Capturing: "#components/*.js" -> "./src/components/*.alias" - TypeScript “paths” field and Node.js subpath imports can handle this - This class can rewrite extensions in a path - Multiple Capturing, Regular Expressions or JavaScript functions - You can do everything with this class
to a runtime! Node.js can handle them - All alias should starts with “#” - “#/” is exceptionally prohibited. “@” and “~” are prohibited. why? - Cleanly distinguishable within Node.js import path resolution algorithm and absolutely no match to existing packages - `@` is used in a scoped module, `~` is also used in the home directory - 📝 `#` reminds us of URL fragments, private fields, etc. - Just rewrite! - No fallback for multiple entries - No extension guessing / Folders as modules, evenly for CommonJS - This behavior matches to Import Maps spec 🔗 - Folders as modules is replaceable by package.json “exports” field
- Use subpath imports - Native ESM. No fallback. - Sloppy ESM, Fake ESM, CJS and “extensions everywhere” style also fine - 💡Sloppy ESM + “extensions everywhere” is almost Native ESM - Use tsconfig.json or jsconfig.json - Sloppy ESM, Fake ESM, CJS - If you are using path aliases for limited use, you may be able to safely use subpath imports. - Otherwise, continue to use the paths field for import path consistency in your project. - Fallback behavior required - For react-native users - You cannot rely on subpath imports. Native extensions do not work. - For Deno users - Continue with deno.json "imports" field. It has only exact and prefix matching capabilities.
“imports” / “exports”: - Literally all of the ecosystem tools that need to resolve modules, they will re-implement an official logic to handle each field. - To handle tsconfig.json: 4 packages, AFAIK - To handle “imports” / “exports”: 2 packages - There are
the import path alias - Let's write a central settings file and try to convert from it. - There is 2 options: TypeScript “paths” field, Node.js Subpath imports feature - Subpath imports takes an advantage because it applies to a runtime - However, it will affect the consistency of paths in non-Native ESM projects. - If you could move to "extension everywhere" style, subpath imports can apply them - Or if you can tolerate import paths with and without extensions - If you use Native ESM, you can safely adapt Subpath imports
are slowly moving toward a Native ESM world. - It's a slow process, because it's not really a problem without much awareness. - Different people are creating different gradients and taking it forward. - The current state, especially Subpath imports behavior is confusing, actually. - I guess Extension guessing and Folders as Modules are used by many people, in many projects. How do you think? - While it would be attractive to be able to resolve paths statically, this currently makes it difficult to accommodate subpath imports. - I'd like to talk to someone who knows what's going on.
- Native ESM is recommended regardless of whether it is apps or libs - It writes in the "extension everywhere" style and does not use “sloppy” behavior - For apps development: Sloppy ESM style is also fine (but) - When writing in Native ESM with TypeScript, the *.ts extension is valid. - Some environments have emerged that can run TypeScript directly. Keep watching. - To simplify the configuration of the import path alias: Single Source of Truth - There is 2 options: TypeScript “paths” field, Node.js Subpath imports feature - If you use Native ESM, you can safely adapt Subpath imports - For non-Native ESM codebases, it's difficult to apply subpath imports. - Keep an eye on what's going on with Subpath imports.