You might not need TypeScript project references
If you've worked in a larger TypeScript codebase or monorepo, you are likely familiar with project references. 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
).
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
).
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
blog post.
Vite
Internal packages just work. No extra config is needed.
React Native
If you use Expo and use the expo-yarn-workspaces
or @turborepo/adapter-expo
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. 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.