You might not need TypeScript project references

You might not need TypeScript project references

Jared Palmer
Name
Jared Palmer
X
@jaredpalmer

If you've worked in a larger TypeScript codebase or monorepo, you are likely familiar with project references (opens in a new tab). They are indeed fairly powerful.

When you reference a project in your tsconfig.json, new things happen:

  • Importing modules from a referenced project will instead load its output declaration file (.d.ts)
  • If the referenced project produces an outFile, the output file .d.ts file’s declarations will be visible in this project
  • Running build mode (tsc -b) will automatically build the referenced project if it hasn't been built but is needed
  • By separating into multiple projects, you can greatly improve the speed of typechecking and compiling, reduce memory usage when using an editor, and improve enforcement of the logical groupings of your program.

Sounds awesome! Right?! Well...maybe. Once you add references to your project you now need to continuously update them whenever you add or remove packages. That kinda blows.

Well...what if you didn't need to?

"Internal" TypeScript Packages

As it turns out, you might not even need references or even an interim TypeScript build step with a pattern I am about to show you, which I dub "internal packages."

An "internal package" is a TypeScript package without a tsconfig.json with both its types and main fields in its package.json pointing to the package's untranspiled entry point (e.g. ./src/index.tsx).

{
  "name": "@sample/my-internal-package"
  "main": "./src/index.ts"
  "types": "./src/index.ts", // yes, this works!
  "dependencies": {
    ...
  },
  "devDependencies": {
    ...
  }
}

As it turns out, the TypeScript Language Server (in VSCode) and Type Checker can treat both a raw .ts or .tsx file as its own valid type declaration. This last sentence is obvious once you read it twice. What isn't so obvious, though, is that you can point the types field directly to raw source code.

Once you do this, this package can then be used without project references or a TypeScript build step (either via tsc or esbuild etc) as long as you adhere to 2 rules:

  • The consuming application of an internal package must transpile and typecheck it.
  • You should never publish an internal package to npm.

As far as I can tell, this internal package pattern works with all yarn/npm/pnpm workspace implementations regardless of whether you are using Turborepo or some other tool. I have personally tested this pattern with several different meta frameworks (see below), but I'm sure that it works with others as well.

Next.js

Next.js 13 can automatically transpile and bundle dependencies from local packages (like monorepos) or from external dependencies (node_modules).

/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ['@acme/ui', 'lodash-es'],
};
 
module.exports = nextConfig;

As of Next.js 13.1, you no longer need the next-transpile-modules package. For more information, visit the Next.js Built-in module transpilation (opens in a new tab) blog post.

Vite

Internal packages just work. No extra config is needed.

React Native

If you use Expo (opens in a new tab) and use the expo-yarn-workspaces (opens in a new tab) or @turborepo/adapter-expo (opens in a new tab) package, you can use internal packages as long as you are targeting iOS or Android. When you run Expo for these platforms, all of node_modules are automatically transpiled with Metro (opens in a new tab). However, if you are targeting Expo for web, internal packages will not work because node_modules are oddly not transpiled for web.

I reached out to the Expo team about this inconsistency. They are aware of it. It's a legacy wart I'm told.

The beauty of this pattern

This pattern rocks because it saves you from extra needless or duplicative build steps. It also gives you all the editor benefits of project references, but without any configuration.

Caveats

When you use an internal package, it's kind of like telling the consuming application that you have another source directory—which has pros and cons. As your consuming application(s) grow, adding more internal packages is identical to adding more source code to that consuming application. Thus, when you add more source code, there is more code to transpile/bundle/typecheck...so this can result in slower builds of the consuming application (as there is just more work to do) but potentially faster (and less complicated) overall build time. When/if overall build time begins to suffer, you might decide to convert your larger internal packages back into "regular" packages with .d.ts files and with normal TypeScript build steps.

As previously mentioned, this pattern actually has very little to do with Turborepo. It's just super duper awesome and I think you should be aware of it. As we are actively working on preset package build rules (i.e. "builders") for Turborepo, we'll using the internal package pattern to skip build steps.

Speaking of long build times...

Shameless plug here. If you are reading this post, and you're struggling with slow build and test times, I'd love to show you how Turborepo can help. I guarantee that Turborepo will cut your monorepo's build time by 50% or more. You can request a live demo right here. (opens in a new tab)