After a deep dive looking up best practices for TypeScript monorepos recently, I couldn't find anything that suited my needs:
- Publish individual (typed) packages to NPM with minimal config.
- Supports fast unit testing that spans the entire project (e.g., via Vitest)
- Ability to have an interactive playground to experiment with the API in a real-time (e.g., via Vite)
Most solutions point to TypeScript project references, but only support my first requirement and make the latter much worse compared to a non-monorepo.
Here's a pattern I've settled on that works well for me, relying solely on TypeScript.
{
"compilerOptions" {
"module": "nodenext",
"moduleResolution": "nodenext"
...
}
}
This config requires .js
extensions in your source code. E.g., in your
TypeScript files you will write:
// index.ts
import x from "./x.js";
Even though ./x.js
is actually x.ts
on the file system. TypeScript won't
touch imports, so using nodenext
ensures the emitted JavaScript files are
valid ECMAScript modules.
This means you can publish the compiled code directly to NPM for others. No bundlers or extra configuration. Just use TypeScript.
{
"name": "@sample/lib-a",
"type": "module",
"version": "0.1.0",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
}
}
I was surpised to find out that the TypeScript Language Server and Type
Checker can use .ts
or .tsx
files as valid type declarations. So, tweaking
your package exports
to point source .ts
allows your package to
be used internally without project references or a TypeScript build step.
I couldn't find much documentation on this approach, but then happily came across
Jared Palmer's (creator of Turborepo) post "You might not need TypeScript project references".
I've adopted the name "Internal TypeScript package" but recommended using modern package
exports
over main
and type
fields.
In Jared's post, he explicitly states that you should never publish an "internal TypeScript package" to npm. This is true, but what if you are working on a library for others? Are you out of luck?
Nope. We just need the exports
field in our package.json
to
point to valid type defitions .d.ts
and JavaScript .js
for the registry and not our
TypeScript source .ts
.
The trick: Override the exports
field to point to dist/index.d.ts
and dist/index.js
in our package.json
just before publishing to npm.
{
"name": "@sample/lib-a",
"type": "module",
"version": "0.1.0",
"exports": {
".": {
-- "types": "./src/index.ts",
-- "import": "./src/index.ts"
++ "types": "./dist/index.d.ts",
++ "import": "./dist/index.js"
}
}
}
This method provides a "switch" to externalize our internal packages for library consumers. Locally, we get all the benefits of the "internal TypeScript package", but end users get typed ESM packages.
There are two ways to apply these "just in time" overrides, depending on your package manager:
- Option 1 Use
publishConfig
(pnpm only)
The
publishConfig
is a handy field in your package.json
that lets you override fields when
publishing your package when publishing with pnpm publish
. Other package managers treat
publishConfig
differently, so this method only works for pnpm.
{
"name": "@sample/lib-a",
"type": "module",
"version": "0.1.0",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
}
- Option 2
prepack
andpostpack
scripts (pnpm, npm, and yarn)
Alternatively, you can use the prepack
and postpack
life cycle scripts
to modify the package.json
before publishing (and restore it to its orginal state after).
{
"name": "@sample/lib-a",
"type": "module",
"version": "0.1.0",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"backup": "cp package.json package.json.backup",
"prepack": "npm run backup && node scripts/prepack.mjs",
"postpack": "node scripts/postpack.mjs"
}
}
A separate gist contains an example of these scripts.
With everything set up, the publishing process is straightforward:
pnpm tsc -b # build all TypeScript projects with `nodenext`, creating dist/
pnpm publish
Note: I recommend using the
publint
CLI before publishing to ensure that each of the files in yourexports
matches their specified locations.
This approach provides all the internal benefits of project references without configuration. Moreover, tools like Vite and Vitest, which handle TypeScript automatically, work seamlessly across your codebase, just like they would without a monorepo setup.
However, as with any method, there are caveats. Jared's post delves deeper into these concerns.