35.7 Publishing TypeScript Packages from a Monorepo
Right, so you’ve built this beautiful monorepo, a symphony of interconnected packages, and now you want to share one of those masterpieces with the world. Publishing a TypeScript package isn’t just about running npm publish and hoping for the best. If you do that, you’ll end up shipping your src directory, your tsconfig.json, and probably your half-eaten lunch, which is not what consumers of your package signed up for. They want clean, runnable JavaScript and type definitions. Let’s get you from a messy workspace to a pristine published package.
The Heart of the Matter: Your package.json
This file is your package’s birth certificate, passport, and nutritional label all in one. NPM uses it to figure out what to actually bundle up and send. The most critical fields for a TypeScript package are main, types, exports, and files.
{
"name": "@my-awesome-monorepo/cool-library",
"version": "1.0.0",
"description": "A library that does cool things, obviously.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist/"
],
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}
See that main and types? That’s the old-school way of pointing to your entry point. main tells Node.js where your JavaScript lives, and types tells TypeScript where your type definitions are. The files array is a whitelist; it tells NPM exactly which directories to include. Without it, they use a default list that might grab READMEs and licenses, but I never leave it to chance. The exports field is the modern, more powerful approach. It allows you to define different entry points for different environments (ESModules vs. CommonJS) and neatly bundle the types alongside them. It’s a bit more verbose, but it’s unequivocally the right way to do it now.
Taming the TypeScript Compiler (tsconfig.json)
Your library’s tsconfig.json should be different from your application’s. An app can get away with messy, inline source maps and just-in-time compilation. A library must be pristine. You need to output production-ready JS and .d.ts files.
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "dist"]
}
The magic flags here are "declaration": true (generates the .d.ts files) and "declarationMap": true (which lets users “Go to Definition” into your original source code, a nice touch). Notice we’re excluding test files and the dist directory itself. You don’t want to accidentally compile your already-compiled output. That way leads to madness and circular logic.
The prepublishOnly Life Saver
Look back at the package.json example. That "prepublishOnly": "npm run build" script is non-negotiable. It’s an npm lifecycle hook that runs automatically before your package is packed and published. This ensures that the dist directory containing the latest build is always included in the published tarball. Without it, you’ll inevitably forget to build, publish an old version of dist, and then spend an hour confused about why the bug you just fixed is still happening. I’ve done it. You’ll do it. Let’s just avoid it.
The Dev Dependency Trap
This is a classic monorepo gotcha. You have typescript, jest, and eslint installed in the root of your monorepo. Fantastic. But your published package doesn’t care about the root. When someone installs your package, npm only looks at its dependencies and peerDependencies.
{
"name": "@my-awesome-monorepo/cool-library",
"dependencies": {
"lodash": "^4.17.21" // This will be installed with your package.
},
"devDependencies": {
"typescript": "^5.0.0" // This WON'T be. It's correct.
},
"peerDependencies": {
"react": ">=18.0.0" // This forces the user to provide it.
}
}
The rule is simple: anything your package needs to run goes in dependencies. Anything your package needs to be built (TypeScript, Jest, Rollup) goes in devDependencies. And anything that you expect the consumer to have installed for you (like a specific framework, e.g., React or Vue) should be a peerDependency. This keeps your package’s install footprint lean and avoids the dreaded “duplicate dependency” hell where two versions of React get installed.
The Entry Point: Barrel Files are Your Friend
A well-structured src/index.ts barrel file is the public API of your library. It’s the bouncer at the club, deciding what gets exported and what stays internal.
// src/index.ts
export { CoolComponent } from './components/CoolComponent';
export { useCoolHook } from './hooks/useCoolHook';
export { coolUtility } from './utils/coolUtility';
// DO NOT export this. It's for internal use only.
// export { internalHelperFunction } from './utils/internalStuff';
This is where you maintain control. You can change the internal structure of your code drastically, but as long as you keep the exports from this barrel file consistent, you won’t break your users’ code. It’s a contract. Don’t be a jerk and break it in a patch version.
Versioning and Publishing: The Final Step
From your monorepo root, you use your chosen tool (lerna, nx, turbo, changesets) to handle the nightmare of versioning and publishing. Manually cd-ing into each package and running npm publish is a recipe for human error and despair. A tool like changesets is brilliant because it forces you to write a changelog for each change, which then automatically bumps the version and publishes it.
# Using Lerna
npx lerna version patch --yes
npx lerna publish from-package --yes
# Or using Changesets
npx changeset version
npx changeset publish
The --yes flag is because you’ve already tested everything and you’re not a coward, right? (Always test everything). This process ensures all inter-dependent packages in your monorepo get their versions bumped correctly together. Publishing @my-awesome-monorepo/cool-library@2.1.0 and @my-awesome-monorepo/other-lib@1.5.0 in one coordinated move is what makes a monorepo powerful instead of just a directory of unrelated mess.