Authoring and Publishing TypeScript Libraries
Reviewed & published by Brayan K
Turn your TypeScript into a polished npm package. You'll emit .d.ts declarations with declaration: true, wire up main/types/exports, build dual ESM + CJS output with tsup or tsc, follow semantic versioning, and npm publish so consumers get IntelliSense for free.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Emit .d.ts files with declaration: true
- Wire up main, types, and the exports field
- Build dual ESM + CJS output with tsup or tsc
- Pick the right semantic version bump
- Control the tarball with the files array
- npm publish so consumers get IntelliSense
💡 Real-World Analogy
Publishing a library is like boxing up a product for sale. The compiled .js is the product itself; the .d.ts files are the instruction manual that lets buyers use it correctly without opening it up; package.json is the label telling the shop where everything is; and semantic versioning is the edition number that tells returning customers exactly what changed since last time.
1. Emit Declaration Files
The single most important setting for a library is "declaration": true — it makes tsc emit .d.ts files describing your public API. Those declarations are what give consumers type-safety and autocomplete. Read the real config here:
// tsconfig.json — emit declarations so consumers get types
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"declaration": true, // <- emit .d.ts files
"declarationMap": true, // <- map .d.ts back to source for go-to-def
"outDir": "dist",
"rootDir": "src",
"strict": true
},
"include": ["src"]
}
// Running "tsc" now produces, for src/index.ts:
// dist/index.js (the runnable JavaScript)
// dist/index.d.ts (the TYPES consumers import)2. The package.json Contract
package.json tells consumers where everything lives: main (CommonJS entry), types (the .d.ts), and the modern exports field that defines per-condition entry points for import vs require. The files array controls what actually ships. Read it here:
// package.json — the contract consumers read
{
"name": "my-lib",
"version": "1.0.0", // SemVer: MAJOR.MINOR.PATCH
"main": "./dist/index.cjs", // CommonJS entry (require)
"module": "./dist/index.js", // ESM entry (legacy bundlers)
"types": "./dist/index.d.ts", // the .d.ts so editors get IntelliSense
"exports": { // modern, condition-based entry points
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js", // when consumer uses import
"require": "./dist/index.cjs" // when consumer uses require
}
},
"files": ["dist"], // only ship the build, not src/tests
"scripts": { "build": "tsup src/index.ts --format esm,cjs --dts" }
}
// Then: npm publish3. What Actually Ships
When you run npm publish, only the files allowed by the files array (plus a few always-included files like README.md) go into the tarball. Crucially, your .d.ts files must be in there — that's how consumers get types. The box models the filtering:
// When you publish, npm bundles ONLY what the "files" array allows.
// Let's MODEL that filtering so you can see what ends up in the tarball.
const projectFiles = [
"src/index.ts", // source — stays OUT
"src/index.test.ts", // tests — stays OUT
"dist/index.js", // build — ships
"dist/index.cjs", // build — ships
"dist/index.d.ts", // types — ships (gives consumers IntelliSense)
"README.md", // always included by npm
"package.json", // always included by npm
];
// "files": ["dist"] -> include anything under dist/ (plus the always-on files)
const filesField = ["dist"];
const alwaysIncluded = ["README.md", "package.json"];
function isPublished(path) {
if (alwaysIncluded.includes(path)) return true;
return filesField.some((dir) => path.startsWith(dir + "/"));
}
console.log("Published in the tarball:");
for (const f of projectFiles) {
if (isPublished(f)) console.log(" ✅ " + f);
}
console.log("Excluded:");
for (const f of projectFiles) {
if (!isPublished(f)) console.log(" ❌ " + f);
}
// Note the .d.ts is shipped — that is what gives consumers types.4. Semantic Versioning
Every publish needs a version. SemVer is MAJOR.MINOR.PATCH: bump patch for a fix, minor for a backwards-compatible feature, and major for a breaking change (which resets minor and patch to 0). Following it lets consumers upgrade with confidence. The box shows the bumping rules:
// SEMANTIC VERSIONING is MAJOR.MINOR.PATCH. Each change type bumps a part:
// PATCH -> backwards-compatible bug fix
// MINOR -> backwards-compatible new feature
// MAJOR -> a BREAKING change (resets minor and patch to 0)
function bump(version, kind) {
let [major, minor, patch] = version.split(".").map(Number);
if (kind === "major") { major += 1; minor = 0; patch = 0; }
else if (kind === "minor") { minor += 1; patch = 0; }
else if (kind === "patch") { patch += 1; }
return major + "." + minor + "." + patch;
}
console.log(bump("1.4.2", "patch")); // 1.4.3 (a fix)
console.log(bump("1.4.2", "minor")); // 1.5.0 (new feature, minor resets patch)
console.log(bump("1.4.2", "major")); // 2.0.0 (breaking, resets minor + patch)
// In real projects: npm version patch | minor | major
// updates package.json AND creates a git tag for you.5. Your Public API Surface
A library's public API is exactly what its entry file exports — anything not exported stays private. Because you ship the matching .d.ts, TypeScript consumers get autocomplete and checking on those exact names. The box models the import experience:
// A library's PUBLIC API is exactly what its entry file exports.
// We model an index module and show how a consumer imports from it.
// src/index.ts (modelled): only these names are public.
const myLib = {
add: (a, b) => a + b,
multiply: (a, b) => a * b,
VERSION: "1.0.0",
// a helper we DON'T export stays private to the package
};
// A consumer: import { add, multiply, VERSION } from "my-lib";
const { add, multiply, VERSION } = myLib;
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log("version:", VERSION); // version: 1.0.0
// Because the package ships a .d.ts describing these exports, a TypeScript
// consumer gets autocomplete and type checking for add/multiply/VERSION.
console.log("Consumers get IntelliSense from the shipped .d.ts files.");Your turn. Fill in the two blanks marked ___ to bump a version for a new feature, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Bump a version for a NEW, backwards-compatible feature.
function bump(version, kind) {
let [major, minor, patch] = version.split(".").map(Number);
if (kind === "minor") { minor += 1; patch = ___; } // 👉 reset patch to 0
return major + "." + minor + "." + patch;
}
// A new feature is a MINOR bump:
console.log(bump("2.3.4", ___)); // 👉 the string "minor"
// ✅ Expected output:
// 2.4.0Common Errors (and the fix)
- ❌ Consumers see any everywhere — you forgot the types. ✅ Set "declaration": true and point "types" at the emitted .d.ts.
- ❌ The .d.ts isn't in the package — your files array excluded it. ✅ Include the dist folder in "files".
- ❌ require() fails for ESM-only output — no CommonJS build. ✅ Emit dual ESM + CJS (for example with tsup --format esm,cjs).
- ❌ A breaking change shipped as a patch — consumers break on a "safe" upgrade. ✅ Bump the major version for any breaking change.
Pro Tips
- 💡 Ship the types — declaration: true plus a types field is what makes your library feel native to TS users.
- 💡 Prefer exports — define conditional import/require/types entries; keep main/types for older tooling.
- 💡 Whitelist with files — publish only dist so source and tests never leak into the tarball.
- 💡 Use npm version — it bumps SemVer and tags the commit, so releases stay traceable.
Mini-Challenge: Publish-Readiness Checker
No blanks this time — write isReadyToPublish(pkg) yourself, requiring name, version, types, and main, then run it against a complete and an incomplete package.
// 🎯 MINI-CHALLENGE: a publish-readiness checker
//
// Write isReadyToPublish(pkg) that returns true ONLY if the package.json
// object has ALL of these set to non-empty values:
// 1. pkg.name
// 2. pkg.version
// 3. pkg.types (so consumers get type definitions)
// 4. pkg.main (an entry point)
// Otherwise return false.
//
// Test with the two objects below.
//
// ✅ Expected output:
// true
// false
const good = { name: "my-lib", version: "1.0.0", types: "./dist/index.d.ts", main: "./dist/index.js" };
const bad = { name: "my-lib", version: "1.0.0", main: "./dist/index.js" }; // no types!
// your isReadyToPublish here, then:
// console.log(isReadyToPublish(good));
// console.log(isReadyToPublish(bad));🎉 Lesson Complete
- ✅ "declaration": true emits the .d.ts files consumers rely on
- ✅ main, types, and exports tell tools where each build lives
- ✅ Dual ESM + CJS output (via tsup or tsc) serves both module systems
- ✅ SemVer MAJOR.MINOR.PATCH communicates exactly what changed
- ✅ The files array and npm publish ship the build — including the types
- ✅ You've finished the TypeScript course! Go build and publish something.
Practice quiz
Which tsconfig option makes tsc emit .d.ts declaration files?
- "strict": true
- "declaration": true
- "emitTypes": true
- "types": []
Answer: "declaration": true. Setting "declaration": true tells tsc to generate .d.ts files alongside the compiled JavaScript.
What does the "types" field in package.json point to?
- The entry .d.ts declaration file for consumers
- The license
- The test folder
- The README
Answer: The entry .d.ts declaration file for consumers. "types" (or "typings") points to the package's main .d.ts so editors give consumers IntelliSense.
What is the purpose of shipping .d.ts files with your package?
- To replace the JavaScript
- To make the package smaller
- So consumers get type checking and autocomplete without your source
- To run the tests
Answer: So consumers get type checking and autocomplete without your source. The .d.ts files describe your public API, so TypeScript users get IntelliSense and type-safety from the compiled package.
What does the "main" field in package.json specify?
- The test command
- The CommonJS entry point that gets loaded by require()
- The author's name
- The git repository
Answer: The CommonJS entry point that gets loaded by require(). "main" is the entry module Node loads via require(), usually the compiled CommonJS build.
What does the modern "exports" field add over "main"?
- It runs the build
- It publishes the package
- Nothing, it is identical
- It lets you define separate entry points per condition, like import vs require
Answer: It lets you define separate entry points per condition, like import vs require. "exports" supports conditional entry points (import, require, types) and controls exactly what is importable.
In semantic versioning, which part increments for a BREAKING change?
- The major number
- The build number
- The patch number
- The minor number
Answer: The major number. SemVer is MAJOR.MINOR.PATCH; a breaking change bumps MAJOR, new compatible features bump MINOR, and fixes bump PATCH.
Which command publishes your package to the npm registry?
- npm release
- npm publish
- npm deploy
- npm build
Answer: npm publish. npm publish uploads the package (respecting files/.npmignore) to the registry for others to install.
What is a common tool for building dual ESM + CJS output with types?
- eslint
- vitest
- nodemon
- tsup
Answer: tsup. tsup (built on esbuild) bundles TypeScript into ESM and CJS and can emit .d.ts files in one step; plain tsc also works.
Why ship BOTH ESM and CommonJS builds?
- CJS is faster to download
- To double the file count for fun
- So both modern import users and older require() users can consume the package
- ESM is required by npm
Answer: So both modern import users and older require() users can consume the package. Dual output means consumers on either module system can use your library without friction.
Where do you control which files are actually included in the published tarball?
- The "scripts" field
- The "files" array in package.json (or .npmignore)
- The tsconfig include
- The README
Answer: The "files" array in package.json (or .npmignore). The "files" array whitelists what gets published (typically dist), keeping source and tests out of the tarball.
Continue this course
- Previous: Building APIs with Express and TypeScript
- Quick reference: TypeScript cheat sheet
Frequently asked questions
How do I make sure consumers get type definitions?
Set "declaration": true in tsconfig so tsc emits .d.ts files next to the JavaScript, then point package.json's "types" field (and the "types" condition inside "exports") at the main .d.ts. Include the build folder in the "files" array so the declarations are actually published. With those in place, editors give TypeScript consumers full IntelliSense and type checking from your compiled package, without your source.
What's the difference between "main", "module", "types", and "exports"?
"main" is the classic CommonJS entry loaded by require(). "module" is an older convention pointing bundlers at the ESM build. "types" tells TypeScript where the declarations are. "exports" is the modern, preferred field: it defines conditional entry points (import, require, types) and also restricts exactly which paths consumers can import. New packages should define "exports" and keep "main"/"types" for backwards compatibility.
Why ship both ESM and CommonJS builds?
Consumers live in both worlds: modern projects use import (ESM), while plenty of existing code still uses require() (CommonJS). Emitting both — for example with tsup's --format esm,cjs — and wiring them through the "exports" conditions means anyone can install your library and use it with their module system of choice, with no interop headaches.
How does semantic versioning guide what I publish?
SemVer is MAJOR.MINOR.PATCH. Bump PATCH for a backwards-compatible bug fix, MINOR for a backwards-compatible new feature, and MAJOR for a breaking change (which resets minor and patch to 0). Following it lets consumers trust version ranges like ^1.2.0 to upgrade safely. The npm version command bumps package.json and tags the commit for you; then npm publish ships it.