Skip to content

Instantly share code, notes, and snippets.

@schickling
Created January 23, 2026 08:50
Show Gist options
  • Select an option

  • Save schickling/d05fe50fe4ffb1c2e9e48c8623579d7e to your computer and use it in GitHub Desktop.

Select an option

Save schickling/d05fe50fe4ffb1c2e9e48c8623579d7e to your computer and use it in GitHub Desktop.
Comprehensive comparison of bun vs pnpm for monorepo package linking, TypeScript TS2742 errors, and the enableGlobalVirtualStore solution

Bun vs pnpm: Monorepo Package Linking & TypeScript TS2742

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.

The Problem: TS2742 Type Portability Errors

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.

Why This Happens

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.


How Bun Handles file: Protocol

Bun takes a unique approach to local package linking:

Directory Structure Created

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/

Key Characteristics

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

Why This Works for TypeScript

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.


How pnpm Handles file: vs link: Protocol

pnpm offers two distinct protocols for local dependencies, with very different behaviors:

file: Protocol

{
  "dependencies": {
    "my-package": "file:../my-package"
  }
}

Behavior:

  • Creates a copy in the .pnpm content-addressable store
  • Dependencies resolved from CONSUMER's node_modules context
  • Treated like a published tarball
  • Changes require pnpm install to 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

link: Protocol

{
  "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

Key Insight

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.


The pnpm TS2742 Problem and Solution

The Problem with Separate Lockfiles

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.

The Solution: enableGlobalVirtualStore

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=true

How It Works

Instead 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!

Result

  • Same dependency version = same physical path
  • TypeScript sees identical paths across all packages
  • TS2742 errors eliminated
  • Works even without workspace configuration

Comparison Table

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

Recommendations

For Monorepos with Independently Published Packages

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 install

Why this combination:

  1. link: ensures source-local dependency resolution - The linked package uses its own node_modules, matching bun's behavior
  2. enableGlobalVirtualStore ensures path consistency - All projects resolve same-version deps to identical paths
  3. TypeScript happy - No TS2742 errors during declaration emit
  4. Development friendly - Changes immediately visible without reinstall

Configuration Snippet

# .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"
    }
  }
}

When to Use What

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

Summary

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: + enableGlobalVirtualStore fully 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment