Setting Up TypeScript Type Sharing in a Monorepo with pnpm workspace:* and Turborepo
When first setting up a monorepo — managing multiple apps in a single repository — the part where most people stumble is sharing types between packages. You create a shared type package called @myapp/types, configure paths in tsconfig.json, and then get an error saying the types can't be found when you actually run it. Most people have experienced this at least once. When I first set up a monorepo, I ignored the difference between workspace:* and a plain version range, and ended up in the absurd situation where CI was pulling a package from the registry that didn't exist locally.
By the end of this article, you'll have a clear understanding of why your shared type package isn't resolving types, which to choose between the JIT approach and the compiled approach, and how Turborepo automatically determines build order. The key is to explicitly declare dependency boundaries with workspace:* and let Turborepo automatically determine build order along those boundaries.
Core Concepts
The workspace:* Protocol — Preventing Local Packages from Being Fetched from the Registry
There are two ways to reference an internal package in a pnpm workspace.
// ❌ Declaring only a range can cause pnpm to fetch from npm if the local version falls outside the range
"@myapp/types": "^1.0.0"
// ✅ Force pnpm to look only locally using the workspace: protocol
"@myapp/types": "workspace:*"When you use workspace:*, pnpm will never resolve that package from an external registry. If it's not available locally, it simply fails. This may seem inconvenient at first, but it completely prevents unexpected external versions from sneaking in during CI. There's also workspace:^ and workspace:~, but since version range control between internal packages is essentially unnecessary, using workspace:* as the default is recommended.
Automatic substitution on publish: When publishing packages via
pnpm publishor Changesets,workspace:*is automatically replaced with the actual version number (e.g.,"^1.2.0"). The publish flow stays clean without any extra scripts.
Turborepo's Role — Turning pnpm's Graph into Task Order
While pnpm manages the dependency relationships between packages, Turborepo uses those relationships to determine which packages to build first. Here is the complete picture of the basic configuration.
// turbo.json
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}In "dependsOn": ["^build"], the ^ means "the build of all packages this package depends on must complete first." If apps/web depends on @myapp/types via workspace:*, Turborepo will automatically run packages/types's build first. You don't need to specify the order manually.
The "persistent": true on the dev task tells Turborepo that this is a long-running task — like a web server or a file watcher process — that doesn't terminate. Without this flag, Turborepo may incorrectly treat dev as a task it should wait for completion on.
The reason typecheck has "dependsOn": ["^build"] is also important. When using the compiled package approach, the dependency package's .d.ts files must exist before type checking can run. If you're only using the JIT approach, this dependency may not be necessary, but it's safer to declare it explicitly in mixed setups.
JIT Packages vs. Compiled Packages — The Most Important Choice
The way to tell them apart is simple. The table below sums it up at a glance.
| JIT (Just-in-Time) | Compiled Package | |
|---|---|---|
main entry point |
.ts file directly |
dist/index.js |
| Build step | None | Requires tsc or tsup |
| Type change reflection | Immediate | After rebuild |
| Turborepo cache | ❌ Not eligible | ✅ Cache applied |
| Configuration complexity | Low | Medium–High |
| Supported environments | Bundler environments only (Next.js, Vite) | All environments |
There's one important pitfall here. In the JIT approach, if you expose a .ts file directly as the entry point — like "main": "./src/index.ts" — it only works in environments where a bundler handles TypeScript (Next.js, Vite, etc.). In environments like NestJS where the Node.js runtime executes files directly, a .ts entry point will produce an error. If your monorepo includes a NestJS API server, you cannot reference a JIT package from it as-is — you need to switch to a compiled package or handle it separately.
Honestly, the right answer depends on your team size and number of packages. For small setups with 3–4 packages, JIT is much more convenient. Once you exceed 10 packages or CI build times start growing, it's time to move to compiled packages.
Practical Application
Example 1: Setting Up a Shared Type Package with the JIT Approach
This is the fastest way to get started. packages/types exposes the TypeScript source directly, and the consuming app compiles it. Just remember: this approach only works in apps where a bundler handles TypeScript, such as Next.js or Vite.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"my-monorepo/
├── apps/
│ ├── web/ # Next.js app (can consume JIT)
│ └── api/ # NestJS app (be careful with JIT — compiled package recommended)
├── packages/
│ ├── types/ # Shared types (JIT)
│ └── tsconfig/ # Shared TypeScript configuration
├── pnpm-workspace.yaml
└── turbo.json// packages/types/package.json
{
"name": "@myapp/types",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": ""
}
}Setting the build script to an empty string may look odd. The reason is to ensure this package is correctly included in the graph when the typecheck task's "dependsOn": ["^build"] chain runs. Even an empty declaration behaves differently from having no declaration at all.
// packages/types/src/index.ts
export interface User {
id: string;
email: string;
role: "admin" | "member";
}
export interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}Now reference it from the consuming app.
// apps/web/package.json
{
"name": "@myapp/web",
"dependencies": {
"@myapp/types": "workspace:*"
}
}// apps/web/tsconfig.json
{
"extends": "@myapp/tsconfig/nextjs.json",
"compilerOptions": {
"paths": {
"@myapp/types": ["../../packages/types/src"]
}
}
}The paths configuration explicitly tells TypeScript where to find the .ts entry point when using a JIT package. I once trusted exports alone without paths and wasted an hour on type errors. Nesting paths configuration inside packages/types itself can break type resolution, so avoid using paths in the JIT package's own tsconfig.json.
Example 2: Assuming Packages Have Grown — Leveraging Turborepo Cache with Compiled Packages
When packages with build artifacts are added — like a UI component library — or when you need to share types in a runtime environment without a bundler, such as NestJS, the compiled package approach is the right choice. Turborepo's cache is also only properly utilized with this approach.
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts"
},
"devDependencies": {
"tsup": "^8.0.0"
}
}tsup is an external package from the npm registry, so it must be declared with a version like "^8.0.0". workspace:* can only be used for packages inside the monorepo. Writing workspace:* here will cause pnpm install to fail immediately.
// packages/ui/tsconfig.json
{
"extends": "@myapp/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}With a compiled package, you don't need to set paths separately in apps/web/tsconfig.json. Because the .d.ts files in dist/ serve as the entry points, TypeScript naturally follows the exports field to find them.
// apps/web/src/components/UserCard.tsx
import type { User } from "@myapp/types"; // JIT (Next.js bundler environment)
import { Button } from "@myapp/ui"; // Compiled package
export function UserCard({ user }: { user: User }) {
return <Button>{user.email}</Button>;
}Example 3: Shared TypeScript Configuration Package
Separating tsconfig.json into its own package lets all apps and packages maintain consistent TypeScript settings. The extends field is a standard TypeScript feature that inherits settings from another tsconfig.json — extracting this into an internal package lets you manage strict options or target versions in one place.
// packages/tsconfig/package.json
{
"name": "@myapp/tsconfig",
"version": "0.0.0",
"private": true,
"exports": {
"./base.json": "./base.json",
"./nextjs.json": "./nextjs.json",
"./nestjs.json": "./nestjs.json"
}
}// packages/tsconfig/base.json
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "bundler"
}
}"moduleResolution": "bundler" is an option introduced in TypeScript 5.0. It uses the module resolution strategy suited for bundler environments like Webpack, Vite, and esbuild, and correctly recognizes the exports field in package.json. If you're on TypeScript 4.x, you can use "node16" or "nodenext" instead.
Declaring @myapp/tsconfig in devDependencies with workspace:* lets each app reference it as "extends": "@myapp/tsconfig/nextjs.json".
Pros and Cons Analysis
Advantages
The strength of this approach is that the configuration explicitly communicates intent.
| Item | Details |
|---|---|
| Explicit dependency isolation | workspace:* blocks access to undeclared internal packages, making package boundaries clearly visible in the codebase |
| Immediate type reflection | With the JIT approach, changes to shared types are reflected in consuming apps immediately without a rebuild |
| CI cache utilization | With the compiled package approach, unchanged packages leverage Turborepo's cache, dramatically reducing build times |
| Publish automation | workspace:* is automatically replaced with the actual version on publish |
| Disk efficiency | pnpm's content-addressable store shares duplicate packages via hard links |
Content-addressable store: pnpm's approach of storing packages once in a global store and linking each project to them via hard links. Installing the same package across multiple projects doesn't use duplicate disk space.
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| JIT runtime environment limitations | .ts entry points only work in bundler environments (Next.js, Vite); they error in Node.js runtimes like NestJS |
Switch affected packages to the compiled approach when runtime environments like NestJS are involved |
| JIT cannot be cached | Without its own build step, it isn't eligible for Turborepo caching | Consider a gradual migration to compiled packages as the number of packages grows |
paths configuration conflicts |
Nesting paths inside a JIT package itself can break type resolution |
Avoid using paths in the JIT package's own tsconfig.json |
| Type error propagation | A type error in a shared package causes cascading type check failures across all consuming packages | Run packages/ builds before apps/ in CI |
exports field required |
Without it, the internal structure of dist/ may be exposed externally |
Explicitly define exports in all internal packages |
| Configuration complexity | Compiled packages have many management points: tsc, exports, tsconfig, build tasks, etc. |
Simplify builds with tsup, or start with JIT and migrate gradually |
The Most Common Mistakes in Practice
-
Declaring only a version range without
workspace:*— Writing something like"@myapp/types": "^0.0.0"can unexpectedly pull from an external source if the local version falls outside the range or if a package with the same name exists in the registry. Always useworkspace:*for internal packages. -
Omitting even an empty
buildscript for JIT packages — It's tempting to think that since JIT packages don't build, they don't need abuildscript. However, this can cause unintended ordering issues in thetypecheck"dependsOn": ["^build"]chain. Adding even an empty script like"build": ""ensures predictable behavior. -
Forgetting
dist/for compiled packages in.gitignore— Committing build artifacts to git pollutes PRs withdist/changes in every code review. It's recommended to add the patternpackages/*/distto.gitignore.
Closing Thoughts
The core of type sharing in a monorepo is explicitly declaring internal package boundaries with pnpm's workspace:* and letting Turborepo automatically manage build order and caching along those boundaries.
Three steps you can try right now:
-
Create
pnpm-workspace.yamlat the root, declarepackages/*andapps/*, then create thepackages/typesandpackages/tsconfigdirectories. Each directory only needs a minimalpackage.jsonandsrc/index.ts. -
Add
"@myapp/types": "workspace:*"to the consuming app'spackage.jsonand runpnpm install. You can verify thatnode_modules/@myapp/typesis symlinked to the local path. -
Create
turbo.jsonat the root, add"build": { "dependsOn": ["^build"] }, then runpnpm turbo build. You can see directly in the logs that Turborepo automatically sequences execution in the order@myapp/types→apps/web.
Once you've completed these three steps, you can expand further by separating a shared ESLint configuration into its own package, or introducing Changesets to automate version management and CHANGELOG generation.
References
- pnpm Workspaces Official Documentation
- Turborepo Official Documentation - Internal Packages
- Turborepo Official Documentation - TypeScript Guide
- Turborepo Blog - You might not need TypeScript project references
- Turborepo 2.7 Release Notes
- pnpm Catalogs Official Documentation
- How we configured pnpm and Turborepo for our monorepo | Nhost