Types of Monorepo Packages

In a typical TS monorepo, there are three ways of consuming the packages that you created:

  1. Shipping out the TS source code directly in the exports field of the package.json file or using tsconfig paths.
  2. Transpiling the TS code to JS using tsc and shipping the JS code in the exports field of the package alongside the .d.ts files.
  3. Bundling the TS code using something like vite, tsup or rollup and shipping the bundled JS code in the exports field of the package alongside the .d.ts files.

Comparing the approaches

Let’s compare the three approaches to see which one is the best for our use case and why we chose the approach that we did.

Shipping out the TS source code directly

Typically, a package using this approach would be called an “Internal package”.

Advantages

  • Easy to maintain: You don’t have to worry about any build step, you just ship out the source code and let the consumer (like Next.js) handle the transpilation through transpilePackages option. Read docs here
  • Easy to use: You just edit the code. The types & code are immediately reflected on the consumer side, this is called “Live types”.

Disadvantages

  • Slow TS LSP: Let’s imagine you have an api package that uses TS heavy libraries like tRPC, drizzle-orm add a bit of zod here and there. As your API grows more complex, the TS LSP (Language Server Protocol) will start taking hits. This is because TypeScript is so much faster at evaluating the .js and .d.ts combination than pure source .ts files.

It is not uncommon to see the TS LSP taking 1.5-3 seconds for every autocompletion suggestion to come up, the same goes for error reporting. This absolutely kills your productivity, and switching to building those packages immediately fixes it.

In essence, if your consumer is referring to your package’s TS source files, there will be a ceiling to hit where the TS LSP will start slowing down.

Transpiling the TS code to JS

Typically, a package using this approach would be called a “Compiled Package”.

Advantages

  • Fast TS LSP: As mentioned above, TypeScript is so much faster at evaluating the .js and .d.ts combination than pure source .ts files.
  • Better for consumers: The consumer doesn’t have to worry about the transpilation step, it’s already done for them.
  • No loss of features: You are still able to get go to definition and other features in your editor through tsc outputting source & declaration map files.

Disadvantages

  • Build step: For the consumer to get any code updates that we did in the package, we have to run tsc on it.

Bundling the TS code

Typically, a package using this approach would be called a “Publishable Package”.

Advantages

  • Fast TS LSP: As mentioned above, TypeScript is so much faster at evaluating the .js and .d.ts combination than pure source .ts files.
  • Smaller bundle size: You can use tree-shaking and other optimizations to make the bundle size smaller.
  • Output to multiple formats: You can output to multiple formats like ESM and CJS, making it easily used in different environments.

Disadvantages

  • Slower build step: Because bundling not only transpiles your code from TS to JS, but it also does many optimisations like tree-shaking, minification, etc. This makes the build step slower than just transpiling the code, even with tsup which is very fast we have seen pure tsc being much faster just because it avoids that bundling step.
  • Hard to get go to definition: It’s notoriously more difficult to get go to definition and other features in your editor with bundled code.

The OrbitKit Approach

  • ui package is bundled with vite.
  • env package is shipped out as source code.
  • All other packages are compiled with tsc.

This is because:

  • We intend to let the ui package be easily published to NPM, without putting any assumptions over the consumer and that it might be, this is why a bundling step was beneficial here.
  • The env package is only used internally to validate environment variables using t3-env, the TS LSP footprint is not a concern here and hence, we can ship out the source code directly.
  • All other packages are compiled with tsc because they are used internally and we want to keep the TS LSP footprint low, we can use a combination of the awesome turborepo filtering to minimise the dev watch processes running while development.

In short, tsc was the best of both worlds, we get the security of knowing that we will have a fast TS LSP even with a growing codebase, and we get all of the benefits of internal packages like go to definition, etc.

To minimise the amount of processes running during dev we can do something like:

  1. Evaluate the scope of the changes that we are making.
  2. Filter to packages that we will be working on in the dev script, or reverse by ignoring the packages that we won’t be working on.

This looks something like this, if we were to be making api changes for example:

bun turbo dev --filter=@orbitkit/web... --filter=!./packages/config/* --filter=!@orbitkit/ui --only

It is true that this is a really long command, but we can make it shorter by adding it to the root package.json scripts like so:

{
  "scripts": {
    "dev:web:api": "bun turbo dev --filter=@orbitkit/web... --filter=!./packages/config/* --filter=!@orbitkit/ui --only"
  }
}

Or something along those lines, these are just examples, and it’s preferred for you to make these scripts according to your needs.

Conclusion

This is why we have all the packages in the monorepo built in different ways. If you have any questions or feedback about this approach, feel free to open an issue on the repository or reach out to me on X/Twitter. Happy Monorepoing! 🚀