This guide shows a simple before/after flow when using duel --exports to emit package.json exports.
Tip
Convention over Configuration
The --exports option is designed to be zero-config. It determines your public API by scanning your build output and applying standard Node.js patterns. It assumes that your directory structure reflects your intended module boundaries. If you need to hide specific files or create complex custom mappings, you should manage the exports field manually; duel is built to handle the 90% of use cases that follow standard project layouts without requiring a separate configuration file.
package.jsonhas"type": "module"and noexportsfield.tsconfig.jsonusesoutDir: "dist".- Running
duelproduces ESM indistand CJS indist/cjs.
Example layout (source tree):
src/index.tssrc/components/button.tssrc/components/card.tssrc/utils/math/add.tssrc/utils/math/subtract.ts
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}Keys stay extensionless; targets keep explicit extensions. Values are concrete (no wildcards) because each file gets its own subpath. The subpath key is derived from the file name (via path.parse().name), not its directory path.
Warning
If two files share the same basename (e.g., foo.ts in different folders), they collide on that subpath: the later file discovered by the glob pass overwrites the earlier one.
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/index.js"
},
"./index": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/index.js"
},
"./button": {
"types": "./dist/components/button.d.ts",
"import": "./dist/components/button.js",
"require": "./dist/cjs/components/button.cjs",
"default": "./dist/components/button.js"
},
"./card": {
"types": "./dist/components/card.d.ts",
"import": "./dist/components/card.js",
"require": "./dist/cjs/components/card.cjs",
"default": "./dist/components/card.js"
},
"./add": {
"types": "./dist/utils/math/add.d.ts",
"import": "./dist/utils/math/add.js",
"require": "./dist/cjs/utils/math/add.cjs",
"default": "./dist/utils/math/add.js"
},
"./subtract": {
"types": "./dist/utils/math/subtract.d.ts",
"import": "./dist/utils/math/subtract.js",
"require": "./dist/cjs/utils/math/subtract.cjs",
"default": "./dist/utils/math/subtract.js"
}
}
}Directory-based keys are emitted with a trailing /*; values are wildcarded to cover all files under that directory.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/index.js"
},
"./components/*": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.js",
"require": "./dist/cjs/components/*.cjs",
"default": "./dist/components/*.js"
},
"./math/*": {
"types": "./dist/utils/math/*.d.ts",
"import": "./dist/utils/math/*.js",
"require": "./dist/cjs/utils/math/*.cjs",
"default": "./dist/utils/math/*.js"
}
}
}Wildcard keys use the first path segment and cover folders; values are wildcarded to match all files in that segment. With the same layout as above, keys group by the first directory (components, utils) instead of the deepest one.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/index.js"
},
"./components/*": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.js",
"require": "./dist/cjs/components/*.cjs",
"default": "./dist/components/*.js"
},
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"import": "./dist/utils/*.js",
"require": "./dist/cjs/utils/*.cjs",
"default": "./dist/utils/*.js"
}
}
}- Keys are extensionless to keep the public API stable; targets carry
.js/.cjs/.d.tsso Node resolution stays explicit. - For
dir/wildcard, both keys and values use wildcards (./dir/*->./dist/dir/*.jsetc.). - The root
.entry uses yourmain(if set) to pick the default orientation (import vs require) and mirrors both builds when present. - If
mainis absent and no non-wildcard subpath exists,.is not promoted. - Windows paths are normalized with
path.posix.
If you want to constrain which built files become exports while keeping a conventional layout, pass --exports-config <file>. The file must be JSON with this shape:
{
"entries": ["./dist/index.js", "./dist/folder/module.js"],
"main": "./dist/index.js"
}entries(required): array of strings pointing to emitted files (relative with./). Only these bases are exported.main(optional): overrides themainused for the root.entry and default orientation.
Convention over configuration remains the default: if you omit --exports-config, duel scans the output and infers exports automatically.
Use --exports-validate to compute and validate the exports map without writing package.json. Combine with --exports and/or --exports-config to emit after validation. When run alone, it logs success and leaves your package.json untouched.