{"id":928,"date":"2026-07-27T23:43:27","date_gmt":"2026-07-27T15:43:27","guid":{"rendered":"https:\/\/vinta.ws\/code\/?p=928"},"modified":"2026-07-27T23:43:27","modified_gmt":"2026-07-27T15:43:27","slug":"exports-imports-and-modules-in-modern-typescript-javascript","status":"publish","type":"post","link":"https:\/\/vinta.ws\/code\/exports-imports-and-modules-in-modern-typescript-javascript.html","title":{"rendered":"Exports, Imports, and Modules in Modern TypeScript\/JavaScript"},"content":{"rendered":"<p>Things start getting confusing when your npm package wants to support both ECMAScript Modules (ESM) and CommonJS (CJS), which makes it a dual-package.<\/p>\n<p>Assume we have a package whose file structure looks like this:<\/p>\n<pre class=\"line-numbers\"><code class=\"language-text\">.\/\n\u251c\u2500\u2500 src\/\n\u2502   \u251c\u2500\u2500 shared\/\n\u2502   \u2502   \u2514\u2500\u2500 index.ts\n\u2502   \u251c\u2500\u2500 node\/\n\u2502   \u2502   \u251c\u2500\u2500 cli.ts\n\u2502   \u2502   \u251c\u2500\u2500 index.cts\n\u2502   \u2502   \u2514\u2500\u2500 index.ts\n\u2502   \u2514\u2500\u2500 browser\/\n\u2502       \u251c\u2500\u2500 pangu.ts\n\u2502       \u2514\u2500\u2500 pangu.umd.ts\n\u251c\u2500\u2500 dist\/\n\u2502   \u251c\u2500\u2500 shared\/\n\u2502   \u2502   \u2514\u2500\u2500 index.js\n\u2502   \u251c\u2500\u2500 node\/\n\u2502   \u2502   \u251c\u2500\u2500 cli.js\n\u2502   \u2502   \u251c\u2500\u2500 index.cjs\n\u2502   \u2502   \u2514\u2500\u2500 index.js\n\u2502   \u2514\u2500\u2500 browser\/\n\u2502       \u251c\u2500\u2500 pangu.js\n\u2502       \u2514\u2500\u2500 pangu.umd.js\n\u2514\u2500\u2500 package.json<\/code><\/pre>\n<h2>Entry Points<\/h2>\n<p>Let's start from <code>package.json<\/code>. Here is a simplified one from <a href=\"https:\/\/github.com\/vinta\/pangu.js\">pangu.js<\/a>:<\/p>\n<pre class=\"line-numbers\"><code class=\"language-json\">{\n  \"name\": \"pangu\",\n  ...\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": {\n      \"import\": {\n        \"types\": \".\/dist\/node\/index.d.ts\",\n        \"default\": \".\/dist\/node\/index.js\"\n      },\n      \"require\": {\n        \"types\": \".\/dist\/node\/index.d.cts\",\n        \"default\": \".\/dist\/node\/index.cjs\"\n      }\n    }\n  },\n  ...\n}<\/code><\/pre>\n<p>Simply speaking, the entry points of a package are (usually) defined in <code>package.json<\/code>'s <code>exports<\/code> field:<\/p>\n<ul>\n<li><code>import xxx from &quot;pangu&quot;<\/code> matches the <code>&quot;import&quot;<\/code> branch, which loads <code>.\/dist\/node\/index.js<\/code> =&gt; ESM<\/li>\n<li><code>require(&quot;pangu&quot;)<\/code> matches the <code>&quot;require&quot;<\/code> branch, which loads <code>.\/dist\/node\/index.cjs<\/code> =&gt; CommonJS<\/li>\n<\/ul>\n<h3>Condition Matching Order<\/h3>\n<p>In the above <code>package.json<\/code>, you may have noticed that every branch puts <code>&quot;types&quot;<\/code> first. That's not a style choice.<\/p>\n<p>Within the <code>exports<\/code> object, <strong>key order is significant<\/strong>. During condition matching, the resolver (Node.js, TypeScript, or any bundler) walks the object from top to bottom, takes the first key it recognizes, then stops. Everything below the winner is ignored.<\/p>\n<ul>\n<li><code>&quot;types&quot;<\/code> must go <strong>first<\/strong>, so that TypeScript always sees your <code>.d.ts<\/code><\/li>\n<li><code>&quot;default&quot;<\/code> must go <strong>last<\/strong>. Anything you list after it is unreachable<\/li>\n<\/ul>\n<p>ref:<br \/>\n<a href=\"https:\/\/nodejs.org\/api\/packages.html#conditional-exports\">https:\/\/nodejs.org\/api\/packages.html#conditional-exports<\/a><br \/>\n<a href=\"https:\/\/www.typescriptlang.org\/docs\/handbook\/modules\/reference.html#packagejson-exports\">https:\/\/www.typescriptlang.org\/docs\/handbook\/modules\/reference.html#packagejson-exports<\/a><\/p>\n<h2>ECMAScript Modules (ESM)<\/h2>\n<p>ESM is the current standard module system in the JavaScript specification. You should really consider using it if you're not.<\/p>\n<p>A piece of code is an ECMAScript module if any of the following is true:<\/p>\n<ul>\n<li>A file ends with <code>.mjs<\/code> or <code>.mts<\/code><\/li>\n<li>A file ends with <code>.js<\/code> or <code>.ts<\/code> AND <code>package.json<\/code> has <code>&quot;type&quot;: &quot;module&quot;<\/code>\n<ul>\n<li>When <code>tsconfig.json<\/code> sets <code>&quot;module&quot;: &quot;esnext&quot;<\/code> or <code>&quot;moduleResolution&quot;: &quot;bundler&quot;<\/code>, every <code>.ts<\/code> file is treated as ESM no matter what <code>&quot;type&quot;<\/code> says<\/li>\n<\/ul>\n<\/li>\n<li>Inside a <code>&lt;script type=&quot;module&quot;&gt;<\/code> tag<\/li>\n<\/ul>\n<pre class=\"line-numbers\"><code class=\"language-typescript\">\/\/ export\nexport class NodePangu {}\nexport const pangu = new NodePangu();\nexport default pangu;\n\n\/\/ import from a file (inside the same package)\nimport { Pangu } from '..\/shared\/index.js'; \/\/ relative imports need the extension in ESM, and you write .js even though the source file is index.ts\n\n\/\/ import from a package\nimport pangu from 'pangu'; \/\/ it loads .\/dist\/node\/index.js according to package.json[\"exports\"][\".\"][\"import\"][\"default\"]\nimport { pangu, NodePangu } from 'pangu';<\/code><\/pre>\n<p>The mapping:<\/p>\n<ul>\n<li><code>export default pangu;<\/code> =&gt; <code>import pangu from &#039;pangu&#039;;<\/code><\/li>\n<li><code>export const pangu = new NodePangu();<\/code> =&gt; <code>import { pangu } from &#039;pangu&#039;;<\/code><\/li>\n<li><code>export class NodePangu {}<\/code> =&gt; <code>import { NodePangu } from &#039;pangu&#039;;<\/code><\/li>\n<\/ul>\n<p>ref:<br \/>\n<a href=\"https:\/\/www.typescriptlang.org\/docs\/handbook\/2\/modules.html\">https:\/\/www.typescriptlang.org\/docs\/handbook\/2\/modules.html<\/a><br \/>\n<a href=\"https:\/\/nodejs.org\/api\/esm.html\">https:\/\/nodejs.org\/api\/esm.html<\/a><\/p>\n<h2>Subpath Exports<\/h2>\n<p>You just define an extra key in <code>exports<\/code>, <code>.\/browser<\/code> in our case:<\/p>\n<pre class=\"line-numbers\"><code class=\"language-json\">{\n  \"name\": \"pangu\",\n  ...\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": {\n      ...\n    },\n    \".\/browser\": {\n      \"import\": {\n        \"types\": \".\/dist\/browser\/pangu.d.ts\",\n        \"default\": \".\/dist\/browser\/pangu.js\"\n      }\n    }\n  },\n  ...\n}<\/code><\/pre>\n<p>The <code>export<\/code> part is the same, but you will need to add the <strong>subpath<\/strong> (<code>\/browser<\/code>) when importing.<\/p>\n<pre class=\"line-numbers\"><code class=\"language-typescript\">\/\/ export\nexport class BrowserPangu {}\nexport const pangu = new BrowserPangu();\nexport default pangu;\n\n\/\/ import from a package\nimport pangu from 'pangu\/browser'; \/\/ it loads .\/dist\/browser\/pangu.js according to package.json[\"exports\"][\".\/browser\"][\"import\"][\"default\"]\nimport { pangu, BrowserPangu } from 'pangu\/browser';<\/code><\/pre>\n<p>It's worth noting that in ESM, only <strong>relative<\/strong> imports must include the file extension (<code>.js<\/code>, <code>.cjs<\/code>, or <code>.mjs<\/code>), because they are resolved as plain URLs with no extension guessing. Package names like <code>pangu<\/code> and <code>pangu\/browser<\/code> don't need one, since they are resolved through the <code>exports<\/code> field instead of the filesystem.<\/p>\n<h2>CommonJS<\/h2>\n<p>CommonJS is an old but still widely used module system, especially in the Node.js ecosystem.<\/p>\n<p>A piece of code is a CommonJS module if any of the following is true:<\/p>\n<ul>\n<li>A file ends with <code>.cjs<\/code> or <code>.cts<\/code><\/li>\n<li>A file ends with <code>.js<\/code> or <code>.ts<\/code> AND <code>package.json<\/code> has <code>&quot;type&quot;: &quot;commonjs&quot;<\/code> (or has no <code>&quot;type&quot;<\/code> field)<\/li>\n<\/ul>\n<p>A plain <code>&lt;script&gt;<\/code> tag is not on the list. It's a classic script, not a CommonJS module. Browsers have never supported CommonJS natively, so <code>require()<\/code> only works on the web after a bundler rewrites it.<\/p>\n<pre class=\"line-numbers\"><code class=\"language-typescript\">class NodePangu {}\nconst pangu = new NodePangu();\n\n\/\/ export\nmodule.exports = pangu;\nmodule.exports.NodePangu = NodePangu;\n\n\/\/ import from a file (inside the same package)\nconst { Pangu } = require('..\/shared\/index'); \/\/ the extension is optional in CommonJS\n\n\/\/ import from a package\nconst pangu = require('pangu'); \/\/ it loads .\/dist\/node\/index.cjs according to package.json[\"exports\"][\".\"][\"require\"][\"default\"]\nconst { NodePangu } = require('pangu');<\/code><\/pre>\n<p>There is also a variable named <code>exports<\/code> in CommonJS, which is basically an alias of <code>module.exports<\/code>. Assigning to <code>exports<\/code> only rebinds that local variable, and what actually gets exported is still whatever <code>module.exports<\/code> points to.<\/p>\n<pre class=\"line-numbers\"><code class=\"language-javascript\">\/\/ You can think of it like this in every CommonJS module\nvar exports = module.exports = {};<\/code><\/pre>\n<p>The rule of thumb: just use <code>module.exports<\/code>, never use <code>exports<\/code>.<\/p>\n<p>ref:<br \/>\n<a href=\"https:\/\/nodejs.org\/api\/modules.html\">https:\/\/nodejs.org\/api\/modules.html<\/a><br \/>\n<a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/JavaScript\/Guide\/Modules\">https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/JavaScript\/Guide\/Modules<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Things start getting confusing when your npm package wants to support both ECMAScript Modules (ESM) and CommonJS (CJS), which makes it a dual-package.<\/p>\n","protected":false},"author":1,"featured_media":929,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[11,12,140],"class_list":["post-928","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-about-javascript","tag-javascript","tag-node-js","tag-typescript"],"_links":{"self":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/posts\/928","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/comments?post=928"}],"version-history":[{"count":0,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/posts\/928\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/media\/929"}],"wp:attachment":[{"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/media?parent=928"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/categories?post=928"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/vinta.ws\/code\/wp-json\/wp\/v2\/tags?post=928"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}