Skip to content

Instantly share code, notes, and snippets.

@manzt
Last active May 4, 2024 06:52
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save manzt/222c8e8f4ed35e74514eb756e4ba09bc to your computer and use it in GitHub Desktop.
Save manzt/222c8e8f4ed35e74514eb756e4ba09bc to your computer and use it in GitHub Desktop.
A minimal setup for TypeScript monorepo libraries

My Minimal TypeScript Monorepo Setup for ESM Libraries

After a deep dive looking up best practices for TypeScript monorepos recently, I couldn't find anything that suited my needs:

  1. Publish individual (typed) packages to NPM with minimal config.
  2. Supports fast unit testing that spans the entire project (e.g., via Vitest)
  3. 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.

TypeScript with nodenext resolution

{
  "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.

Make Every Sub-Package an "Internal TypeScript Package"

{
  "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.

Externalize Your Internal TypeScript Packages

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 and postpack 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.

Publishing Workflow

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 your exports matches their specified locations.

Benefits of this Pattern

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.

@cgradwohl
Copy link

hey I am very interested in this type of setup! do you happen to have an example repo I can take a look at?

@manzt
Copy link
Author

manzt commented Dec 6, 2023

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment