In the modern JavaScript ecosystem, publishing production-grade libraries demands supporting a dual runtime execution model: packages must be consumed seamlessly in legacy CommonJS (CJS) environments, native ECMAScript Modules (ESM) runtimes, and next-generation bundlers (Vite, Next.js, Rspack).
Type declaration files (.d.ts) represent the public interface of a library. Improper declaration generation causes severe architectural pitfalls:
moduleResolution: "nodenext") when declaration file extensions (.d.mts vs. .d.cts) diverge from runtime module formats."types" key is placed after "import" or "require" conditions in package.json.A modern package export map links runtime bundles and declaration files across a structured conditional export matrix:
+-------------------------------------------------------------------------+
| DUAL PACKAGE ARCHITECTURE (CJS + ESM) |
+-------------------------------------------------------------------------+
| package.json: |
| "exports": { |
| ".": { |
| "types": { |
| "import": "./dist/index.d.mts", <-- Strict ESM declaration |
| "require": "./dist/index.d.cts" <-- Strict CommonJS declaration |
| }, |
| "import": "./dist/index.mjs", <-- Executable ESM runtime |
| "require": "./dist/index.cjs" <-- Executable CommonJS runtime |
| } |
| } |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| SOURCE LINKING VIA DECLARATION MAPS |
| dist/index.d.ts.map: |
| Maps .d.ts directly back to src/index.ts. |
| IDE "Go to Definition" jumps straight into original TypeScript |
| sources rather than flattened declaration output. |
+-------------------------------------------------------------------------+The fundamental precedence rule in package.json: the "types" condition must always appear first within an export subpath. If the compiler encounters "import" before "types", it may resolve the file in untyped mode or ignore declarations entirely.
declarationMap: true without shipping bloated source trees in production bundles.process.env, augmenting user session contexts in auth libraries (NextAuth/Auth.js), or extending custom JSX element attributes.package.json syntax and type compatibility using @arethetypeswrong/cli (attw) to prevent broken package releases.When building with modern bundlers (tsup, Rollup, esbuild), type generation is delegated to tsc using --emitDeclarationOnly:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist/types",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}declaration: true: Generates .d.ts declaration files.declarationMap: true: Generates .d.ts.map source maps linking declarations back to src/. This allows developers to use F12 ("Go to Definition") to navigate straight into readable source code and documentation rather than generated declaration headers.Here is the canonical package.json manifest structure satisfying both Node.js and TypeScript 5+ module resolution specifications:
{
"name": "@enterprise/telemetry-sdk",
"version": "2.4.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.mts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"prepublishOnly": "attw --pack ."
}
}@arethetypeswrong/cli (attw)#The @arethetypeswrong/cli tool simulates multiple downstream consumer environments (Node10, Node16 CJS, Node16 ESM, Bundler) to uncover extension mismatches and broken export conditions before release.
pnpm dlx @arethetypeswrong/cli --pack .A clean attw validation matrix:
┌───────────────────┬──────────────┬──────────────┬─────────────┐
│ Entrypoint │ node10 │ node16 (esm) │ bundler │
├───────────────────┼──────────────┼──────────────┼─────────────┤
│ . (import) │ 🟢 (esm) │ 🟢 (esm) │ 🟢 (esm) │
│ . (require) │ 🟢 (cjs) │ 🟢 (cjs) │ 🟢 (cjs) │
└───────────────────┴──────────────┴──────────────┴─────────────┘
All checks passed! Package is ready for publication.When a library allows consumers to contribute custom types into an existing contract, Module Augmentation is employed.
// Extending the telemetry library's global event contract
import '@enterprise/telemetry-sdk';
// Augmenting external module declarations
declare module '@enterprise/telemetry-sdk' {
// TypeScript performs automatic interface declaration merging
export interface CustomEventRegistry {
'user.login': { userId: string; timestamp: number; ipAddress: string };
'payment.success': { orderId: string; amountCents: number; currency: 'USD' | 'EUR' };
}
}
// Augmenting global NodeJS ProcessEnv context
declare global {
namespace NodeJS {
interface ProcessEnv {
TELEMETRY_ENDPOINT: string;
TELEMETRY_API_KEY: string;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
}
}
}
// File must be a module (must contain at least one import or export)
export {};Generating dual bundles (index.js, index.cjs) and declaration pairs (index.d.mts, index.d.cts).
Validating package.json export conditions, file paths, and relative reference integrity.
Simulating package consumption across Node16 ESM/CJS and Bundler modes to verify type availability.
Pushing verified tarball artifacts to npm or GitHub Packages with immutable metadata.
Below is a production-tested tsup configuration tuned to build dual CJS/ESM artifacts while emitting corresponding .d.mts and .d.cts declarations:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
// Automated generation of .d.ts files accounting for .d.mts and .d.cts formats
dts: {
compilerOptions: {
declarationMap: true,
},
},
splitting: false,
sourcemap: true,
clean: true,
minify: false,
target: 'es2022',
outExtension({ format }) {
return {
js: format === 'esm' ? '.js' : '.cjs',
};
},
// Keep peer dependencies external
external: ['react', 'react-dom'],
});If "types" appears after "import" in the package.json "exports" block, TypeScript's resolution algorithm under several module modes will match "import" first, ending evaluation before detecting the declaration file.
// ❌ Anti-pattern: types is placed after import (declarations will be ignored!)
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
// ✅ Recommended practice: types MUST ALWAYS be first
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}Writing a declaration file without any top-level import or export statements marks it as an ambient global script. Its types will leak globally across the consumer's entire project, polluting global namespaces and triggering unexpected identifier collisions.
Remedy: Always append an empty export {}; at the end of declaration files declaring ambient blocks.
When declarationMap: true is enabled in tsconfig, but src/ is excluded from the "files" list in package.json, an IDE's "Go to Definition" command attempts to open a non-existent file, throwing a File not found error.
Remedy: Either include src/ in the published package or disable declarationMap for public third-party releases.
Configure tsup or tsc to output dual declarations (.d.mts for ESM, .d.cts/.d.ts for CJS).
Define export conditions ensuring the 'types' property occupies the first position.
Run pnpm dlx publint to identify syntax errors, invalid subpaths, or broken exports.
Execute pnpm dlx @arethetypeswrong/cli --pack . and ensure zero red resolution flags.
Publish to npm via pnpm publish with 2FA and verified provenance tags.