When Modern Technologies Annoy: A DX Odyssey with tRPC, Drizzle, and TypeScript
Optimizing a large-scale TypeScript monorepo development environment with Arktype instead of Zod, structured Project References, and customized VSCode settings reduces compile and autocomplete times to an acceptable level.
When Modern Technologies Become Annoying: A DX Odyssey with tRPC, Drizzle, and TypeScript
It was May of last year. New project, new domain, new codebase, new client, modern technologies, fresh libraries. It was exciting. I was really motivated.
Finally, I got to try out tRPC. The library enables fully type-safe APIs between client and server. I also got to put Drizzle through its paces. For this project, we had to set up a rather extensive database with over 100 tables. At first, everything went smoothly. The codebase grew—and with it, the compile times. The TypeScript compiler noticeably slowed with each new table and every new endpoint.
And yes, the GIF really runs in real time.
Working with a modern stack is all well and good. But the developer experience was a disaster. Even small features became a test of patience, because no one wanted to wait minutes for a blocked IDE. You either memorized the entire project (which isn’t feasible at nearly two million lines of code), or you tediously searched through files. Neither was practical; both were frustrating. It became clear: This couldn’t go on.
And so, one weekend, my desperate search for a solution began. The goal was to tweak the monorepo—with its 5+ apps and 15+ subpackages—so that nobody would immediately lose the desire to work on this project.
Hunting for the Bottleneck
As an experienced developer knows: the problem often sits in front of the screen. So I started digging through our code. I’m not unjustly known at work for sometimes going overboard with the type system. An article from the TypeScript Wiki gave me the first hints.
The code itself was unremarkable. The only exception was zod.
Zod is a popular runtime-validation library for TypeScript. Usually, you don’t run into issues, but in such a large project every little bit adds up.
I replaced almost all Zod types with arktype, an alternative library that the TypeScript compiler can process much faster.
A small comparison:
// before, with zod
const User = z.object({
name: z.string(),
age: z.number(),
});
// after, with arktype
const user = a.type({ name: "string", age: "number" });
And indeed: the critical slow spots vanished. According to the TypeScript compiler’s performance profile, there were no obvious bottlenecks left. Unfortunately, in daily work you hardly noticed any difference. Compile times were measurably shorter, but the IDE remained sluggish.
Since that change didn’t help, I looked further. Eventually I stumbled upon this issue in the tRPC repo. It explained that tRPC can have a significant impact on TypeScript performance, and even offered a solution approach.
The problem is amplified when tRPC is used alongside Drizzle and Zod, because Drizzle’s generic types for complex database queries and Zod’s comprehensive schema validations generate deeply nested type constructions that the TypeScript compiler must fully resolve on every code change.
Prebuild Instead of Rebuild
The idea was simple: libraries or modules that take a long time to build can be precompiled. Then, during development, TypeScript only has to check that the types are used correctly instead of reanalyzing everything from scratch.
So we manually split the project into parts—API, database access, shared types, and so on. For each subproject we started its own tsc --watch
process:
cd packages/api && tsc --watch
cd packages/shared && tsc --watch # and so on
...
cd app/app-you && tsc --watch # are working in
The generated .d.ts
files were then used in the main project. The hope was that TypeScript would now only check against the prebuilt types instead of constantly reanalyzing the entire codebase.
And indeed: autocompletion became usable again. The IDE felt noticeably faster. For a brief moment, I thought the problem was solved.
But as soon as something changed in one of the subprojects, it again took one to two minutes for everything to rebuild. Some processes ran multiple times; others blocked each other. Occasionally the build hung altogether. It became clear: the individual tsc --watch
processes didn’t know about each other.
Understanding Project References and VSCode
Our tsc --watch
processes thought they were completely independent. They knew neither their dependencies nor the correct build order. That’s exactly what TypeScript’s lesser-used feature—Project References—is for. With it, you can define in tsconfig.json
how projects relate.
We configured the root project to reference the subprojects directly:
In packages/api
we declared its dependency on packages/shared
:
// tsconfig.json in packages/api
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist"
},
"references": [
{ "path": "../shared" }
],
"include": ["src"]
}
And of course packages/shared
also needed to be a composite project:
// tsconfig.json in packages/shared
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist"
},
"include": ["src"]
}
The result was a single tsc --watch
process that could coordinate all the projects. A code change now triggered only the necessary rebuild—and it took just about three seconds.
You can imagine the setup as a network: each package is a node, with well-defined paths (references) between them. And VSCode no longer detours through all the source code but reads directly from the built
.d.ts
files.
But introducing Project References brought back an old problem: autocompletion became unbelievably slow again.
The reason: VSCode uses its own TypeScript server, independent of the running build process. For every autocomplete request, VSCode reanalyzes the project from scratch, recognizes the references—and rebuilds the entire project each time. No cache. No regard for files already built.
The fix was a single configuration switch:
{
"compilerOptions": {
"disableSourceOfProjectReferenceRedirect": true
}
}
With this setting, you force VSCode to look directly at the generated .d.ts
files for type information instead of reanalyzing the full source code.
And what can I say? It worked.
Conclusion
Although the watch process and autocompletion are now finally performant and stable, this setup does introduce some maintenance overhead. Whenever a new subproject is added, it must be listed not only in the package.json
but also correctly referenced in the relevant tsconfig.json
files. Only then can the TypeScript compiler work reliably—and only then does the developer experience remain pleasant.
Still: a bit more upfront care beats wrestling with an IDE every day.
)
)
)
)
)
)
)
)
)
)
)
)