I’ve been working on a library to queue a bunch of calls to web pages via https.request, but to spread each request out over time. For the most part, it was ready. However, importing the library into my project resulted in a lack of type definitions. Type definitions allow you to see what type of data the libraries function calls expect.

I initially wrote the small project as ESM node modules, and then went though the process of converting each file over to TypeScript. I thought that it would be simple enough to run the typescript compiler and a definition file would be created. It’s not. Apparently typescript has gone through a few version changes, and with each change, the way that declaring that the files exist in a package has changed – drastically. Initially, you would add “types” to your package.json. Another change introduced “typesVersions”, and a further change added “exports”. At one point I was stuck where VS Code would tell me that it found the typescript file, but that exports prevented it from loading.
Rather than go through the process of how I came up with the solution, let’s just go over what works. We have both a production and development build of the package, where the production build is minified. The main issue is that the typescript resolution seems to need us to target both “CommonJS” and “ESM”.
The typescript compiler had a few limitations in how it could bundle everything as one file, so I opted to use rollup as the bundler.
Package.json
{
"type": "module",
"main": "dist/commonjs/index.min.cjs",
"types": "dist/commonjs/types/index.d.min.ts",
"module": "dist/commonjs/index.min.cjs",
"exports": {
".": {
"import": {
"types": "./dist/esm/types/index.d.min.mts",
"default": "./dist/esm/index.min.js"
},
"require": {
"types": "./dist/commonjs/types/index.d.min.ts",
"default": "./dist/commonjs/index.min.cjs"
}
},
"./production": {
"import": {
"types": "./dist/esm/types/index.d.min.mts",
"default": "./dist/esm/index.min.js"
},
"require": {
"types": "./dist/commonjs/types/index.d.min.ts",
"default": "./dist/commonjs/index.min.cjs"
}
},
"./development": {
"import": {
"types": "./dist/esm/types/index.d.mts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/types/index.d.ts",
"default": "./dist/commonjs/index.cjs"
}
}
},
"typesVersions": {
"*": {
"production": [
"dist/commonjs/types/index.d.min.ts"
],
"development": [
"dist/commonjs/types/index.d.ts"
]
}
},
"scripts": {
"clean": "rm -rf build && rm -rf dist",
"build": "npm run clean && tsc",
"bundle": "npm run build && npx rollup -c ./build/rollup.config.js"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^26.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@types/node": "^22.5.4",
"rollup": "^4.21.2",
"rollup-plugin-dts": "^6.1.1",
"rollup-plugin-license": "^3.5.2",
"rollup-plugin-typescript2": "^0.36.0",
"tslib": "^2.7.0",
"typescript": "^5.5.4"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2016",
"lib": [
"ESNext"
],
"module": "ESNext",
"moduleResolution": "Node10",
"types": [
"node",
],
"outDir": "build",
"importHelpers": true,
"noEmitOnError": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
rollup.config.ts
import fs from 'fs';
import typescript from 'rollup-plugin-typescript2';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import { dts } from 'rollup-plugin-dts';
import license from 'rollup-plugin-license';
import { RollupWatchOptions, OutputOptions, InputPluginOption, OutputPluginOption } from 'rollup';
const read = (file: string) => fs.readFileSync(file, 'utf-8');
const pkg = JSON.parse(read('package.json'));
const dev = pkg.exports['./development'];
const prod = pkg.exports['./production'];
const input = './src/index.ts';
const external = ['http', 'https'];
const name = 'webRequestQueue';
const banner = read('LICENSE.md');
const outFile = (file: string, format: 'esm' | 'commonjs'): OutputOptions => ({
file,
format,
sourcemap: true,
exports: format === 'esm' ? 'auto' : 'named',
plugins: [license({ banner })],
});
const libraryFile = (file: string, format: 'esm' | 'commonjs', production: boolean): OutputOptions => {
const options = outFile(file, format);
if (production) {
(options?.plugins as OutputPluginOption[]).push(terser())
}
if (format === 'esm') options.name = name;
return options;
}
const libraryEnvironments = (devFile: string, prodFile: string, format: 'esm' | 'commonjs'): RollupWatchOptions => {
const plugins: InputPluginOption[] = [
typescript(),
resolve({ browser: false })
];
if (format === 'commonjs') plugins.push(commonjs());
return {
input,
external,
output: [
libraryFile(devFile, format, false),
libraryFile(prodFile, format, true)
],
plugins,
}
}
const config: RollupWatchOptions[] = [
libraryEnvironments(
dev.import.default,
prod.import.default,
'esm'
),
libraryEnvironments(
dev.require.default,
prod.require.default,
'commonjs'
),
{
input,
external,
output: [
outFile(dev.import.types, 'esm'),
outFile(prod.import.types, 'esm'),
outFile(dev.require.types, 'commonjs'),
outFile(prod.require.types, 'commonjs')
],
plugins: [
dts()
],
}
];
export default config;
.npmignore
# Ignore everything * # Except everything in dist folder !dist/**
.gitignore
/node_modules/ /build/ /dist/
The ignore files are aded for some context to ensure only files in the dist folder are packaged, while they are still ignored from the source code repository in git.
The package and rollup config are the grunt of whats going on. The package file feels like it’s polluted with excessive information. The rollup configuration is setup to read the package.json to determine where the dist files should be placed/named.
Getting down to the bottom of things, there are three names to import the final package as ES or CommonJS Modules:
- ECMAScript
- import {foo} from ‘package-name’
- import {foo} from ‘package-name/production’
- import {foo} from ‘package-name/development’
- CommonJS
- const foo = require(‘package-name’).foo
- const foo = require(‘package-name/production’).foo
- const foo = require(‘package-name/development’).foo
Each of the methods has a file for the library and type declaration, as well as source maps for both files. The default and production environment target the same files. Production files are minified.
In addition, the corresponding typescript files are also created. Here is the full list of generated files.
| File | Environment | Module | Type |
|---|---|---|---|
| index.d.min.ts | Production | CJS | Declaration |
| index.d.min.ts.map | Production | Source Map | |
| index.d.ts | Development | CJS | Declaration |
| index.d.ts.map | Development | Source Map | |
| index.cjs | Development | CJS | Library |
| index.cjs.map | Development | Source Map | |
| index.min.cjs | Production | CJS | Library |
| index.min.cjs.map | Production | Source Map | |
| index.d.min.mts | Production | ES | Declaration |
| index.d.min.mts.map | Production | Source Map | |
| index.d.mts | Development | ES | Declaration |
| index.d.mts.map | Development | Source Map | |
| index.js | Development | ES | Library |
| index.js.map | Development | Source Map | |
| index.min.js | Production | ES | Library |
| index.min.js.map | Production | Source Map |
I found that using the terser plugin on type definitions threw errors. Although the type definition files are named with “.min” suffixes, they are not actually minified.
Things of note is that the NPM website will show that your package has typescript files associated with it with a special icons, simply for populating the setting in your package file. It does not validate that the configuration is valid.

I’m noticing that I went through this before with replace-tags. The package has typescript as well, but I hadn’t gone through a nightmare to setup typescript. Reviewing the package.json, I see that I had a dev build via devMain in the package.json, and only used typings. Why was I led down the path to use exports? I’m not seeing any mention of devMain on the documentation for npm package configuration or node packages. I can only assume “devMain” was a suggested alternative, as I’m not seeing it in use in the wild. I’m also noticing that it was built using webpack, which I have been starting to move away from in favor of Vite. Unfortunately, I wasn’t able to determine how to use Vite to bundle libraries since it’s targeted towards websites. Since Vite is built on top of Rollup, so I diverted to used Rollup directly.
Hopefully this helps someone in the future as a reference, or at least a quick reference to reflect on while working on other projects later.
