To create an npm package in TypeScript you need three things working together: a tsconfig.json that emits both declarations and JavaScript, a package.json whose exports map points at the compiled output, and a publish step that ships only the built files. The mechanics take about ten minutes. The part that matters longer-term is making sure the package you ship cannot become a supply-chain liability for everyone who installs it.
Scaffold the project
Start clean and initialize the metadata:
mkdir my-lib && cd my-lib
npm init -y
npm install --save-dev typescript
npx tsc --init
Set up a source directory and a build target. A minimal tsconfig.json for a library:
{
"compilerOptions": {
"target": "ES2021",
"module": "NodeNext",
"declaration": true,
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}
declaration: true is what makes your package usable by other TypeScript consumers, because it emits the .d.ts type files alongside the JavaScript. strict: true catches whole categories of bugs before you ever publish.
Configure package.json for real consumers
The exports field is the modern entry point and it also acts as an encapsulation boundary: anything not listed cannot be imported by consumers. Point it at your compiled output:
{
"name": "my-lib",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
Two lines do a lot of work. The files array is an allow-list: only dist gets published, so you will not accidentally ship your .env, test fixtures, or src. And prepublishOnly guarantees the tarball reflects a fresh build rather than stale artifacts.
Verify what you are about to publish
Before you ever run npm publish, look at the exact tarball npm would upload:
npm pack --dry-run
Read the file list. This one command prevents the most common publishing mistake, which is leaking source, secrets, or build tooling into a public package. If you see anything that is not compiled output, tighten files or add an .npmignore.
Harden against supply-chain attacks
This is where a security-minded package diverges from a tutorial one. npm packages are attacked constantly, both by compromising a maintainer account and by tricking developers into installing a typosquatted name. A few concrete controls:
- Enable two-factor authentication on your npm account and require it for publishing. A stolen password should not be enough to push a malicious version under your name.
- Publish with provenance. Running
npm publish --provenancefrom a CI workflow attaches a signed, verifiable statement linking the published artifact to the exact source commit and build. Consumers can then confirm the tarball really came from your repository. - Audit your own dependencies. Every package you depend on becomes a package your users depend on transitively. Run
npm auditand keep the tree small. Fewer dependencies means less attack surface. - Pin and review dependency updates rather than auto-merging them. The npm dependency confusion attack and typosquatting both exploit developers who install without checking.
An SCA tool such as Safeguard can watch a published package's dependency tree over time and flag when a transitive dependency picks up a known vulnerability, which is useful once your library is out in the world and you are no longer thinking about it daily.
Publish and version responsibly
When the tarball looks right, publish:
npm publish --access public --provenance
After that, treat versioning as a contract. Follow semantic versioning honestly: patch for fixes, minor for backward-compatible additions, major for breaking changes. A surprise breaking change in a patch release erodes trust faster than almost anything, and it forces every consumer to scramble. If you deprecate a version because it contains a security bug, use npm deprecate with a message telling people what to upgrade to.
FAQ
Do I need to ship both ESM and CommonJS?
Only if your consumers include older CommonJS projects. Modern Node and bundlers handle ESM fine, so ESM-only is a reasonable default for a new package. If you do want both, add a require condition to exports pointing at a CommonJS build and produce two output formats.
How do I keep secrets out of my published package?
Use the files allow-list in package.json and confirm with npm pack --dry-run. Never rely on .gitignore alone, because git ignore rules and npm publish rules are separate. Verify the actual tarball contents every release.
What is npm provenance and why does it matter?
Provenance is a signed attestation, generated during a CI publish, that ties your package tarball to the specific source repository and commit that built it. It lets consumers verify the artifact was not tampered with or published from someone's laptop after a token theft.
Should I commit my package-lock.json for a library?
Yes, commit it for reproducible local builds and CI, but note that consumers of your library do not use your lockfile. Their installer resolves your dependency ranges freshly, which is why keeping those ranges tight and audited matters.