A comprehensive comparison of how bun and pnpm handle local package linking in monorepos, specifically addressing TypeScript declaration emit and the TS2742 "type portability" error.
When building a monorepo where:
- Each package has its own lockfile (not using workspace features)
- Packages depend on each other via local
file:references - TypeScript generates declaration files (
.d.ts)
You may encounter errors like:
error TS2742: The inferred type of 'foo' cannot be named without a reference to
'../other-package/node_modules/.pnpm/[email protected]/node_modules/some-dep'.
This is likely not portable. A type annotation is necessary.
TypeScript's declaration emit must write types that are "portable" - meaning they can be resolved from the consuming package. When two packages have the same dependency at the same version but installed at different paths, TypeScript cannot guarantee the types are compatible.
package-a/node_modules/.pnpm/[email protected]/... ← TypeScript sees this path
package-b/node_modules/.pnpm/[email protected]/... ← But emits types referencing this path
Even though both are [email protected], TypeScript treats them as potentially different types because the paths differ.
Bun takes a unique approach to local package linking:
consumer/
└── node_modules/
└── linked-package/ # Regular directory (NOT a symlink)
├── package.json → symlink to source/package.json
├── src/ → symlink to source/src/
├── tsconfig.json → symlink to source/tsconfig.json
└── node_modules/ → symlink to source/node_modules/
| Aspect | Bun Behavior |
|---|---|
| Package directory | Real directory in consumer's node_modules |
| Individual files | Symlinked back to source |
| Nested node_modules | Symlinked to source package's node_modules |
| Dependency resolution | From source package's OWN node_modules |
Because the linked package's node_modules/ points back to the source, all dependency resolution happens from a single canonical location. If both packages depend on [email protected]:
package-a/node_modules/effect/... ← Both resolve here
package-b/node_modules/linked-a/node_modules/ → package-a/node_modules/
TypeScript sees consistent paths, preventing TS2742.
pnpm offers two distinct protocols for local dependencies, with very different behaviors:
{
"dependencies": {
"my-package": "file:../my-package"
}
}Behavior:
- Creates a copy in the
.pnpmcontent-addressable store - Dependencies resolved from CONSUMER's node_modules context
- Treated like a published tarball
- Changes require
pnpm installto sync
consumer/
└── node_modules/
├── .pnpm/
│ └── file+..+my-package/
│ └── node_modules/
│ └── my-package/ # Copy of package
│ └── node_modules/
│ └── shared-dep → ../../[email protected]/... # Consumer's version!
└── my-package → .pnpm/file+..+my-package/.../my-package
{
"dependencies": {
"my-package": "link:../my-package"
}
}Behavior:
- Creates a symlink directly to source directory
- Dependencies resolved from SOURCE's own node_modules
- Changes immediately visible
- Source package's lockfile controls its dependencies
consumer/
└── node_modules/
└── my-package → ../my-package/ # Direct symlink
my-package/
└── node_modules/
└── shared-dep → .pnpm/[email protected]/... # Source's own version
| Protocol | Equivalent To | Dep Resolution | Use Case |
|---|---|---|---|
pnpm file: |
npm pack + install | Consumer's context | Testing published behavior |
pnpm link: |
bun file: |
Source's context | Development linking |
pnpm link: ≈ bun file: in terms of dependency resolution behavior.
When each package has its own lockfile:
monorepo/
├── package-a/
│ ├── pnpm-lock.yaml
│ └── node_modules/.pnpm/[email protected]_abc123/...
├── package-b/
│ ├── pnpm-lock.yaml
│ └── node_modules/.pnpm/[email protected]_def456/...
Even with identical versions, the .pnpm paths differ due to:
- Different resolution hashes
- Different peer dependency contexts
- Lockfile-specific content addressing
TypeScript sees these as different types → TS2742 errors.
Available in pnpm 10.12+, this feature creates a global virtual store that all projects share:
# Enable via environment variable
npm_config_enable_global_virtual_store=true pnpm install
# Or in .npmrc
enable-global-virtual-store=trueInstead of per-project .pnpm directories:
# WITHOUT enableGlobalVirtualStore
project-a/node_modules/.pnpm/[email protected]/...
project-b/node_modules/.pnpm/[email protected]/... # Different path!
# WITH enableGlobalVirtualStore
~/Library/pnpm/store/v10/links/[email protected]/... # Single canonical path
project-a/node_modules/effect → ~/Library/pnpm/store/v10/links/...
project-b/node_modules/effect → ~/Library/pnpm/store/v10/links/... # Same path!
- Same dependency version = same physical path
- TypeScript sees identical paths across all packages
- TS2742 errors eliminated
- Works even without workspace configuration
| Feature | bun file: |
pnpm file: |
pnpm link: |
pnpm link: + globalVirtualStore |
|---|---|---|---|---|
| Linking Method | Directory with file symlinks | Copy in .pnpm store | Direct symlink | Direct symlink |
| Dep Resolution | Source's node_modules | Consumer's context | Source's node_modules | Source's node_modules |
| Changes Visible | Immediately | After install |
Immediately | Immediately |
| TypeScript Paths | Consistent | Varies per consumer | Varies per project | Globally consistent |
| TS2742 Risk | Low | High | Medium | None |
| Matches Published | No | Yes | No | No |
| Multi-project Safe | Yes | Yes | Depends | Yes |
Use pnpm link: protocol + enableGlobalVirtualStore
// package.json in consumer
{
"dependencies": {
"my-local-package": "link:../my-local-package"
}
}# Install with global virtual store
npm_config_enable_global_virtual_store=true pnpm installWhy this combination:
link:ensures source-local dependency resolution - The linked package uses its own node_modules, matching bun's behaviorenableGlobalVirtualStoreensures path consistency - All projects resolve same-version deps to identical paths- TypeScript happy - No TS2742 errors during declaration emit
- Development friendly - Changes immediately visible without reinstall
# .npmrc (project or global)
enable-global-virtual-store=true// package.json
{
"dependencies": {
"local-pkg": "link:../local-pkg"
},
"pnpm": {
"overrides": {
// Ensure consistent versions across packages if needed
"shared-dep": "^3.0.0"
}
}
}| Scenario | Recommendation |
|---|---|
| Development with local packages | pnpm link: + globalVirtualStore |
| Testing published package behavior | pnpm file: |
| Simple single-project linking | pnpm link: (globalVirtualStore optional) |
| Bun migration to pnpm | pnpm link: + globalVirtualStore |
| Avoiding TS2742 entirely | pnpm link: + globalVirtualStore |
The TS2742 "type portability" error stems from TypeScript seeing different filesystem paths for the same dependency across packages.
- Bun naturally avoids this by symlinking the linked package's node_modules back to source
- pnpm
file:exacerbates it by copying packages and resolving deps from consumer context - pnpm
link:partially helps by using source's node_modules - pnpm
link:+enableGlobalVirtualStorefully solves it by ensuring global path consistency
For monorepos with separate lockfiles per package, pnpm 10.12+ with enableGlobalVirtualStore provides the most robust solution, matching bun's effective behavior while maintaining pnpm's powerful dependency management features.
Last updated: January 2026