Local package development
Publish types and build output
Compile only when necessary and align JavaScript entry points, source maps, declarations, and package exports.
TypeScript source is for maintainers. Consumers need JavaScript their runtime can execute, plus type declarations (.d.ts files) if they use TypeScript.
For @acme/slugify-title, we author src/index.ts and publish compiled output in dist/.
Build JavaScript and declarations together
We build with tsdown. Older versions of this course used tsup, but tsup’s README now says it’s not actively maintained and points to tsdown, which takes almost the same config. tsdown needs Node.js 22.18, 24.11 or newer.
Install it together with TypeScript:
npm install --save-dev tsdown typescript@7
tsdown uses TypeScript to write the declarations, and it needs a tsconfig.json for that:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"skipLibCheck": true
},
"include": ["src"]
}
Then create tsdown.config.ts:
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
fixedExtension: false
})
fixedExtension: false keeps the file names this course uses. Our package has "type": "module", so the ESM build is .js and the CommonJS build is .cjs. Without the option, tsdown writes .mjs and .cjs files.
Run:
npm run build
ls dist
You should see:
index.cjs
index.cjs.map
index.d.cts
index.d.ts
index.js
index.js.map
Each format gets its own declarations: index.d.ts describes the ESM build and index.d.cts the CommonJS one.
Point exports at those files, not at src/:
{
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
The order of the keys matters. Node and TypeScript read the conditions from top to bottom and take the first one that matches, so types goes first in each block and default goes last. If you put types after import, the import entry wins and TypeScript never reads your types line. It then looks for a declaration file sitting next to the JavaScript file, which only works as long as the two stay side by side.
Verify from the consumer fixture
In your TypeScript smoke project:
import { slugifyTitle } from '@acme/slugify-title'
const slug: string = slugifyTitle('Hello World!')
Run npx tsc --noEmit. If types resolve, TypeScript found index.d.ts through the types condition in exports.
Common failure: exports aim at missing files
If you rename a build output but forget package.json, Node fails at runtime and TypeScript fails in the editor. After every build config change, run the consumer fixture again.
Pure JavaScript libraries can skip a compile step and publish src/ directly. The moment you add TypeScript or non-standard syntax, a build step becomes part of the public contract.
Break one path on purpose once
Delete dist/index.d.ts, run the consumer TypeScript check, and read the error:
Could not find a declaration file for module '@acme/slugify-title'
That is what your users see when declarations are missing from the tarball. Add a CI step that fails on that error so it never ships again.
Source maps in dist/ help consumers debug minified or compiled output. They are optional for tiny libraries, but I include them when the compiled JS is hard to read.
Keep strict enabled in tsconfig.json for library code. A loose type inside the package becomes a loose type in every project that installs it.
See Build and publish a TypeScript package to npm for a full walkthrough with dual ESM and CJS outputs.
Lesson completed