35.1 Monorepo Challenges: Shared Types and Cross-Package Imports
Alright, let’s get into the real meat of the monorepo: sharing code without creating a circular dependency hellscape. You’ve set up your packages, they’re all in one repo, and now you want @your-app/ui to use a type from @your-app/utils. Seems simple, right? Welcome to the fun part.
The first trap everyone falls into is thinking they can just import directly across packages using relative paths. Don’t. This is the monorepo equivalent of trying to dig a tunnel with a spoon. Your ui package should have no idea where your utils package lives on the file system. They are separate entities, bound by the laws of your package manager (npm, Yarn, pnpm). You must treat them as such. This means using the published package name in your imports, even though the code is sitting right next to you.
// Inside `@your-app/ui/components/Button.tsx`
// ❌ The Bad Way - Fragile, breaks if package structure changes
import { SomeType } from '../../../utils/src/types';
// ✅ The Correct Way - Respects the package boundary
import { SomeType } from '@your-app/utils';
But wait, if you try this right now, TypeScript will probably scream at you. “Cannot find module ‘@your-app/utils’ or its corresponding type declarations.” Calm down, TypeScript, we’re getting there. This error is your cue to set up the project references and internal tooling that make this magic possible.
Project References: Teaching TypeScript the Map
TypeScript’s project references feature is the bedrock of this whole operation. It’s how you tell the TypeScript compiler (tsc) that your packages are related. You need to configure this in each package’s tsconfig.json and in a root tsconfig.json.
Your root tsconfig.json acts as the maestro, orchestrating the build order.
// ./tsconfig.json
{
"files": [],
"references": [
{ "path": "./packages/utils" },
{ "path": "./packages/ui" }
// ... other packages
]
}
Then, in each package’s tsconfig.json, you do two crucial things: 1) set composite: true, and 2) reference any packages you depend on.
// ./packages/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
// ... other options
},
"references": [
{ "path": "../utils" } // This tells TS that ui depends on utils
],
"include": ["src/**/*"]
}
Now, when you run tsc --build --verbose from the root, it will smartly build utils first and then ui, because it understands the dependency graph. This is a million times better than manually orchestrating build scripts.
The Dev-Time Illusion: npm link and Its Smarter Successors
You’re a developer, not a CI/CD server. You need to see changes in utils reflected instantly in ui without publishing to a registry. This is where your package manager’s workspace features come in.
With npm/Yarn/pnpm workspaces, when you run npm install from the root, it doesn’t install each package’s dependencies in ./packages/utils/node_modules. Instead, it hoists them to the root node_modules and creates symlinks for your local packages. It’s a beautiful illusion: @your-app/utils appears in the root node_modules just like any other published package, but it’s actually a symlink pointing to ./packages/utils. This is why the named import import { ... } from '@your-app/utils' works at dev time.
The Declaration Files Gotcha
Here’s the rub. That symlink gets you the source of @your-app/utils. But TypeScript needs type declarations (.d.ts files). If you just run the source through TS in your ui package, it might work, but it’s slow and can lead to weird configuration conflicts.
The professional move is to ensure your build script for utils generates declaration files ("declaration": true in tsconfig). Now, when you import SomeType, TypeScript isn’t reading the raw .ts file from the utils/src directory. It’s reading the compiled type definition from utils/dist/index.d.ts. This is faster, more reliable, and mirrors exactly what an external consumer of your package would experience.
The Barrel File: A Blessing and a Curse
A common pattern is to use a barrel file (index.ts) in each package to re-export its public API. This is great for consumers—they just import from @your-app/utils.
// ./packages/utils/src/index.ts (the barrel)
export * from './types';
export * from './helpers';
// ... etc.
But be warned: a sloppily maintained barrel file is a great way to accidentally export your entire kitchen sink, including that weird internal utility you named __pleaseDoNotUseThis. Be intentional. Export only what you mean to export. This is your public contract; treat it with the respect it deserves.
The Circular Dependency Death Spiral
This is the big one. The monorepo makes it terrifyingly easy to create a circular dependency. If ui imports from utils, and utils imports from ui, you’ve created a logical black hole. Your build will fail, and your self-esteem will take a hit.
The solution is architectural, not technical. Enforce a clear, acyclic dependency graph. Use tools like madge or npm ls to visualize your dependencies and catch these loops early. Often, the fix is to find the common code that both packages need and lift it into a third, foundational package that they can both depend on. It feels like bureaucracy, but it’s the bureaucracy that prevents the entire system from collapsing.