Turborepo: TypeScript gotchas
Let’s say we have a tsconfig that specifies src
folder as input and dist
folder as output.
What are the differences between tsc
, tsc --build
and tsc --incremental
, which package.json scripts do I use, and how would we cache using turbo.json
?
Setup
// tsconfig.json
{
// ...
"include": ["src"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
}
}
// package.json
{
// ...
"scripts": {
// ⬇️ We'll get back to why later
"build": "tsc",
"dev:build": "tsc --incremental",
"dev": "concurrently -n compile,run \"tsc --incremental --watch\" \"nodemon dist/index.js --watch dist\"",
}
}
tsc
tsc
will simply look through our src
folder, and compile output to dist
folder.
If there exists any typescript errors, it will fail and report it.
Running tsc
is “safe”, but it can get slow to do after a while – especially in a larger project.
—noEmit
--noEmit
will run the same
typechecking, but not output any compiled js
/.d.ts
files.
We can use that in a monorepo if we have packages that NextJS transpiles itself.
-b or —build option
-b
or --build
will skip building any files that are “up to date”.
There’s a huge gotcha with this: “up to date” seems to be based on time stamps
(though it’s not found in the documentation, instead see #46661)
on files. This can be unreliable, especially when combined with external tooling
like turbo
. You may then end up with successful builds even when there exists a
type error in the input file.
—incremental
--incremental
will run like tsc
the first time, but also create a
.tsbuildinfo
with hashes of input files.
When running --incremental
the second time, it’ll hash input files and compare
them with the stored version to see if it needs recompiling.
If the file has changed since last build, it will be recompiled.
Unlike -b
or --build
, --incremental
will catch type errors, even if you’ve built before.
There’s a different gotcha here though. It assumes that the .tsbuildinfo
file is in sync with the build
folder.
Test this by running tsc --incremental
, then delete the dist
folder. It won’t reappear after running tsc --incremental
again.
—watch
--watch
will watch our src
folder for changes, and recompile any files that changes.
It consists of an initial build phase, followed by a watch phase.
The watch phase recompiles any file in src
, on change. While the initial build can be affected with --build
or --incremental
, but it won’t affect the watch phase.
Which should I use?
Production (and CI)
When building for production, correctness is more important than speed.
tsc
without any extra args, avoids assuming that .tsbuildinfo
is in sync
with the dist
folder, and not attempting to skip any files.
I’d therefore run tsc
in production, same for CI.
Local build
When building locally, speed being more important than correctness.
I’d therefore run tsc --incremental
when building locally + add tsconfig.tsbuildinfo
to .gitignore
to prevent it affecting other environments (where code might not yet exists in output folder).
Local development
While coding, we’ll frequent updates in a single file at a time. We want to catch type errors as soon as possible, but correctness is not vital.
I’d therefore run tsc --incremental --watch
in development.
Note: There’s some gotchas here if you end up combining tsc --incremental
in some, but not all tasks: tsconfig.tsbuildinfo
will quickly become invalid.
tsc + turbo
When we add turbo caching to our project, we must specify
which files are considered inputs and outputs. This is done in turbo.json
.
// turbo.json
{ "pipeline": {
// ...
"build": {
// Depend on build script in any other packages
"dependsOn": ["^build"],
// Not specifying inputs will default to any
// file in app folder. One may also want to
// optimize this, by only relevant files, e.g.
// inputs: [
// "src",
// "tsconfig.json",
// "package.json"
// ]
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**"
]
},
"dev:build": {
"dependsOn": ["^dev:build"],
"outputs": [
"dist/**",
".next/**",
"!.next/cache/**",
"tsconfig.tsbuildinfo"
]
},
// New script, see updated package below
"dev:watch": {
"cache": false
}
} }
We also make an update of our package.json
scripts:
// package.json
{
// ...
"scripts": {
"build": "tsc",
"dev:build": "tsc --incremental",
// This was our old dev command
"dev:watch": "concurrently -n compile,run \"tsc --incremental --watch\" \"nodemon dist/index.js --watch dist\"",
// In a mono repo, you can simply move the
// dev-command below to the root package.json
//
// Sidenote: we run dev:build first to prevent
// nodemon restarts due to incremental build updates
"dev": "turbo run dev:build && turbo run dev:watch --parallel",
}
}
🎉 TADA! We’re now ready to run dev
and just program.
Conclusion
Caching is hard.
Sidenote: Why an own dev:build script?
You may ask: Why not use dev:build
as the build
script?
That’s a totally reasonable question! It should work just fine for CI and production too. A benefit of doing that (in combination with external cache), would be cache hits on CI due to the commands having been run locally first.
I prefer to have them separate though. By doing that, I can specify different
dependsOn
jobs for each that optimizes for speed locally and correctness on CI.
In the examples above however, it shouldn’t really matter.