Colin McDonnell @colinhacks
published May 30th, 2024
EDIT: A previous version of this post recommended
publishConfig
, operating under the mistaken belief that it could be used to override"exports"
duringnpm publish
. As it turns out,npm
only uses"publishConfig"
to override certain.npmrc
fields likeregistry
andtag
, whereaspnpm
has expanded its use to override package metadata like"main"
,"types"
, and"exports"
. There are a number of reasons you may not wish to strongly couple your deployment logic topnpm
(detailed in thepublishConfig
section below). My updated recommendation is to use a custom export condition pluscustomConditions
intsconfig.json
.Jump into the code: https://github.com/colinhacks/live-typescript-monorepo
In development, your TypeScript code should feel "alive". When you update your code in one file, the effects of that change should propagate to all files that import it instantaneously, with no build step. This is true even for monorepos, where you may not be importing things from a file, but from a local package.
- import { Fish } from "../pkg-a/index";
+ import { Fish } from "pkg-a"
This is vital to TypeScript's value proposition.
This post explains a few strategies you can use to make your TypeScript monorepo feel more alive. Refer to the corresponding repo where you can play around with the code for each strategy: https://github.com/colinhacks/live-typescript-monorepo
The repo contains three subdirectories, each containing a monorepo with the following file structure:
.
├── package.json
├── packages
│ ├── pkg-a
│ │ ├── README.md
│ │ ├── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── pkg-b
│ ├── README.md
│ ├── index.ts
│ ├── package.json
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── tsconfig.json
This is a pnpm monorepo (pnpm-workspace.yaml
) with two packages, pkg-a
and pkg-b
. pkg-b
has a dependency on pkg-a
. Each package has a tsconfig.json
that extends a tsconfig.base.json
in the root of the monorepo.
Here is a quick rundown of the solutions. Don't worry if there are terms you're not familiar with, everything is explained in the breakdown.
"references"
in tsconfig.json
)publishConfig
in package.json
to specify .ts
file in development and .js
file in production. Requires pnpm
.compilerOptions.paths
in tsconfig.json
to override resolution for local package names.package.json#exports
.Note that I'm explaining all the solutions I found for the sake of education, but the recommended solution is #5 (custom export conditions) so feel free to jump ahead if you're just looking for a solution!
The fundamental annoyance here is this: Node.js has an algorithm for module resolution. When it sees an import from a bare specifier like "pkg-a"
, it scans up the directory tree checking for a directory called "pkg-a"
in each node_modules
folder it encounters. Once it finds "pkg-a"
, it reads the package.json
inside and uses the main
and exports
to figure out how to resolve the bare specifier to a file on disk.
The TypeScript server does almost the same thing, though it uses the "types"
field (or the "types"
export condition in "exports"
) to find the declaration file corresponding to a particular package. There are also lots of ways to hack TypeScript's module resolution algorithm. (We'll get to that in a bit.)
But in development, we want things to behave differently. We want the TypeScript server to look at our "raw" .ts
files when resolving imports to other local packages in our monorepo, not the compiled declaration files. Similarly, when we execute our local code (say, when running tests) we want it to "run" our TypeScript source code. You shouldn't need to rebuild your project before running tests.
So we need a way to hijack module resolution both statically (for TypeScript) and at runtime (for Node.js or tools like Vitest). We also need to make sure both of these things are hijacked in a way that they agree with each other. If TypeScript is looking at our src/index.ts
files but Node.js is still importing lib/index.js
, the types may not reflect the runtime behavior of the code. You may have seen this referred to as static-runtime disagreement.
Okay, enough background. Let's get into the details.
The code for this is under the
project-references
subdirectory. Project references are a TypeScript feature that make it easier to split a large TypeScript codebase into chunks that are typechecked separately by the TypeScript server. This can be a huge performance win for large codebases.
In our monorepo, pkg-b
has a dependency on pkg-a
. So in our packages/pkg-b/tsconfig.json
, we can add a reference to pkg-a
like so:
{
"references": [{"path": "../pkg-a"}]
}
Unfortunately the "references"
field is not inherited when you use "extends"
, so you have to declare it in every package's tsconfig.json
. If your monorepo packages have a lot of interconnection, this can get unwieldy fast.
You may also notice that "references"
will end up mirroring the "dependencies"
list in package.json
. These will need to be kept in sync to work as expected. There are tools (nx
) that try to automate this, but the need to run a command to re-generate configs starts to feel like a "build step" in itself...and that's what we're trying to avoid.
There are long-gestating efforts to make this more ergonomic, but there seems to be little movement on this front.
The final nail in the coffin is the difficulty of incorporating "references"
into runtime module resolution. It's purely a TypeScript feature, and no tools incorporate this into their module resolution. So in all likelihood, you'd need to use one of the approaches below in conjunction with project references to achieve true "live types" with runtime .ts
resolution.
There are absolutely scenarios where project references are indispensable. If you have a large enough monorepo, project references may become necessary to avoid re-typechecking the entire codebase when you make a change! But for non-giant projects, they aren't necessary and introduce too much complexity and potential footguns.
"publishConfig"
in package.json
This approach requires
pnpm
to work! ThepublishConfig
field behaves very differently betweennpm publish
andpnpm publish
.
One obvious solution is just to have each package's package.json
point to our .ts
files in package.json
.
{
"name": "pkg-a",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"require": "./src/index.ts",
"types": "./src/index.ts"
},
"./package.json": "./package.json"
}
}
This introduces an obvious and immediate problem. We can't publish our raw .ts
files to npm without breaking most tools. They need to be properly transpiled to JavaScript first. When we run npm publish
, we need these fields to point to the appropriate lib/index.js
and lib/index.d.ts
files.
Many people have written custom build scripts that will duplicate package.json
and rewrite these fields before publishing. Fortunately the good folks at pnpm
have given us a better option: publishConfig
.
{
"name": "pkg-a",
// development config
"exports": "./src/index.ts",
// production config
"publishConfig": {
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"exports": {
".": {
"import": "./lib/index.js",
"require": "./lib/index.js",
"types": "./lib/index.d.ts"
},
"./package.json": "./package.json"
}
}
}
When you run pnpm publish
, pnpm will read the publishConfig
field in package.json
and use those values to override the top-level values for "main"
, "exports"
, and "types"
.
While
npm
supports a field calledpublishConfig
, it only lets you setnpm config
settings likeregistry
andtag
. Sad.
In essence, our top-level exports
, main
, types
, etc. now only apply in development, so we can point these to raw .ts
files! TypeScript will happily parse these files, as will any modern bundler, framework, or a tool like tsx
.
For simplicity, I've only set one field: exports
, and I'm setting it to a simple string value. This has some benefits:
"exports"
object in both the top-level package.json
and "publishConfig"
. (Though you still can if you rely on subpath imports.)"exports"
as a single string, no subpath imports are allowed. That level of strictness is good. It means you can't use subpath imports internally that would be illegal using the configuration specified in publishConfig
.tsx
, esbuild
, Vite, etc. can all happily resolve this import using just the single exports
key. You don't need "main"
unless you're using Node.js 10 or earlier in your development environment."types"
field. It's more than happy to fall back to "exports"
and pull type signatures out of a regular .ts
source file.The big downside is the reliance on pnpm
. That means if you ever run npm publish
by accident, you'll accidentally publish a package that isn't runnable by Node.js 💀 You also can't rely on popular GitHub Actions like JS-DevTools/npm-publish
since those use npm
. This obstacle is surmountable with some tweaks to CI and diligence around publishing, but it is an important gotcha.
"paths"
in tsconfig.json
TypeScript provides another way to "hijack" module resolution: the compilerOptions.paths
.
{
"compilerOptions": {
"paths": {
"pkg-a": ["./packages/pkg-a/src/index.ts"],
"pkg-b": ["./packages/pkg-b/src/index.ts"]
}
}
}
The compilerOptions.paths
option overrides TypeScript's normal module resolution. Any import that matches a key in paths will be resolved to the corresponding file. (If you specify multiple paths, TypeScript will use the first one that exists on your file system.)
This should be added to the tsconfig.json
for every package in your monorepo. You can avoid redundancy by having all of your package tsconfig
s extend a shared tsconfig.base.json
.
Remember, we also need to incorporate this into our runtime module resolution.
The popular tsx
tool by @privatenumbr
does this automatically. This is the best way to run a TypeScript file with Node.js.
$ npm install -g tsx
$ tsx src/index.ts
You can also use tsx
in conjunction with Node.js's --import
flag.
$ npm install tsx
$ node --import tsx src/index.ts
In the Vite/Vitest ecosystem, there is a popular plugin to do the same called vite-tsconfig-paths
.
This solution can be a bit fiddly, and requires some diligence in how you configure per-package tsconfigs
. It should also be noted that the TypeScript team kinda hates tsconfig.paths
, and there are a lot of ways to shoot yourself in the foot with it.
liveDev
mode in tshy
The tshy
(TypeScript Hybridizer) tool is an opinionated tool by the creator of npm
that makes it simple to build ESM and CommonJS packages from your TypeScript source code.
It recently added support for a liveDev
mode that will hardlink your TypeScript source code into ./dist/esm
and ./dist/commonjs
directories. To set this up, install tshy
into devDependencies
.
$ pnpm add tshy --dev
Add the following "tshy"
config to your package.json
:
{
"tshy": {
"liveDev": true
}
}
Then run tshy
in your package directory.
$ npx tshy
For simplicity, add
"tshy"
as the"build"
script in each of your workspaces. Then you can run this script in each workspace with one command from the root of your workspace. (The exact command depends on your package manager.)
This lets VS Code discover your live TypeScript source code without any additional package.json
configuration! And tools like TypeScript and Vitest will be able to resolve your workspace imports to the .ts
files with no additional configuration at all!
The downside is that this requires running tshy
in each of your workspaces packages, which starts to feel like a build step.
The upside is that you only need to do this once! Once your src
files are hard-linked into dist
, you can edit them like normal and those changes are automatically reflected in dist
. (This is how hard links work.)
The downside is that you'll need to re-run
tshy
each time you add a new TypeScript file (due to how hard links work...). Runningtshy --watch
can mitigate the "new file" problem, but for the purposes of this repo, I'm avoiding any solutions that require a file system watcher.
"exports"
This lets you specify your TypeScript files in package.json#exports
under a custom export condition of your choosing.
The mostly widely-utilized export conditions are import
(for specifying ESM code), require
(for specifying CJS code), and types
(for specifying type definitions).
// package.json
{
"name": "pkg-a",
"exports": {
"*": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
}
}
}
But you're allowed to use any string you like as a custom export condition! Export conditions are intended as an open-ended mechanism for users to specify import entrypoints for specific runtimes, bundlers, or other tooling. For instance, "deno"
, "bun"
, and "workerd"
(Cloudflare Workers) all support custom conditions so libraries can ship build that are specific to those runtimes.
Here's how a custom condition might look in your package.json
. There's nothing special about the string "@colinhacks/zod"
here! It could be anything.
// package.json
{
"name": "pkg-a",
"exports": {
"*": {
"import": {
"@colinhacks/source": "./src/index.ts", // must be first, order matters!
"default": "./lib/index.js",
"types": "./lib/index.d.ts"
},
"require": {
"@colinhacks/source": "./src/index.ts",
"default": "./lib/index.cjs",
"types": "./lib/index.d.ts"
}
}
}
}
You should put your custom condition first. Order matters! It's important that your custom condition is the first one in the list (even before
"types"
).
By default, neither TypeScript nor Node.js pays attention to this new "@colinhacks/source"
export condition. You need to tell them to incorporate it into their respective module resolution algorithms.
To tell TypeScript, add "@colinhacks/source"
to the customConditions
field in tsconfig.json
for all packages in your monorepo. (You can avoid redundancy by having all of your package tsconfig
s extend a shared tsconfig.base.json
.)
{
"compilerOptions": {
// include this in the tsconfig for all packages
"customConditions": ["@colinhacks/source"]
}
}
Node.js can accept custom conditions via the --conditions
flag.
node --import=tsx --conditions=@colinhacks/source ./src/index.ts
In the Vite/Vitest ecosystem, this can be configured with resolve.conditions
setting.
// vite.config.json
export default {
resolve: {
conditions: ['@colinhacks/source'],
},
};
I chose "@colinhacks/source"
as the condition name for the sake of uniqueness. You want to pick a condition name that will only be defined in your workspace packages only. If you choose something generic like "source"
, you may have dependencies with the same condition defined in their package.json
. In this case, VS Code will resolve imports of that package using its "source"
files, instead of using it's pre-compiled .d.ts
files from "types"
. This can hurt performance of the TypeScript server and slow down your editor.
Overall, my recommendation is #5: custom export conditions.
pnpm
, which is a non-starter for many projects."exports"
once using your custom condition, then tell your other tools (including TypeScript itself) to pay attention to that condition.As usual the "comments section" for this post is on Twitter. Feel free to make comments or ask questions in the replies to this tweet:
new blog post 👇 it breaks down a few approaches to configuring "live types" in TypeScript monorepos. you should never need to run `build` while developing!
— Colin McDonnell (@colinhacks) May 31, 2024
1. tsconfig paths
2. custom export conditions
3. publishConfig (*my recommended solution)https://t.co/sf6F3CfcsQ
Happy monorepo hacking!