Live types in a TypeScript monorepo

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" during npm publish. As it turns out, npm only uses "publishConfig" to override certain .npmrc fields like registry and tag, whereas pnpm 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 to pnpm (detailed in the publishConfig section below). My updated recommendation is to use a custom export condition plus customConditions in tsconfig.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.

  1. Use project references ("references" in tsconfig.json)
  2. Use publishConfig in package.json to specify .ts file in development and .js file in production. Requires pnpm.
  3. Configure compilerOptions.paths in tsconfig.json to override resolution for local package names.
  4. Recommended Define a custom conditional export condition in 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!

A primer: runtime vs. static

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.

1. Project references

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.

2. "publishConfig" in package.json

This approach requires pnpm to work! The publishConfig field behaves very differently between npm publish and pnpm 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 called publishConfig, it only lets you set npm config settings like registry and tag. 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:

  1. It's clean! You don't need to redundantly declare a big "exports" object in both the top-level package.json and "publishConfig". (Though you still can if you rely on subpath imports.)
  2. It's safe! By specifying "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.
  3. It just works at runtime. Tools like 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.
  4. Similarly, TypeScript is happy. I lied a bit earlier...it doesn't need a special "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.

3. "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 tsconfigs 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.

4. 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...). Running tshy --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.

5. Custom conditions in "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 tsconfigs 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'],
  },
};

A note on condition naming

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.

Conclusion

Overall, my recommendation is #5: custom export conditions.

  • It's clean, easy to configure, and works well with modern tooling.
  • It doesn't require you to use pnpm, which is a non-starter for many projects.
  • You don't need to worry about keeping runtime and TypeScript configurations in sync: you just set "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:

Happy monorepo hacking!