Status: SOLVED - Use
link:protocol (primary) +enableGlobalVirtualStore(for remainingfile:deps)For a detailed comparison of bun vs pnpm monorepo linking behavior, see: https://gist.github.com/schickling/d05fe50fe4ffb1c2e9e48c8623579d7e
When using pnpm with file:../path dependencies in a non-workspace monorepo, TypeScript type-checking fails with TS2742 "type portability" errors.
Root cause: When packages have separate pnpm-lock.yaml files (not a pnpm workspace), pnpm creates isolated .pnpm directories per package. Even if packages depend on the same version of a library (e.g., effect@3.19.14), TypeScript sees different paths during declaration emit, causing TS2742 errors.
Symptoms:
- TS2742 - Type portability errors
Types from dependencies likeerror TS2742: The inferred type of 'X' cannot be named without a reference to '.pnpm/effect@3.19.14/node_modules/effect/Option'. This is likely not portable.effectcannot be resolved across the pnpm symlink boundaries because each package has its own.pnpmdirectory with different paths.
Example error paths showing the problem:
# Same effect version, but different hashed paths!
packages/@example/utils/node_modules/.pnpm/effect@3.19.14_xyz123.../node_modules/effect
packages/@example/common/node_modules/.pnpm/effect@3.19.14_abc456.../node_modules/effect
Different dependency protocols work best in different contexts:
| Context | Protocol | Why |
|---|---|---|
| Same-repo siblings | link:../pkg |
Simple relative paths, own dependency resolution |
| Cross-repo deps | workspace:* |
Works with per-package workspaces, proper symlink resolution |
| External examples/docs | file: + enableGlobalVirtualStore |
Behaves like npm consumers |
For packages within the same repository referencing siblings:
// In effect-utils packages:
"@overeng/utils": "link:../utils"Key insight: pnpm link: creates symlinks where each package uses its OWN node_modules, matching how published packages behave.
Where we use link::
- effect-utils: All
@overeng/*internal packages (via genie catalog)
For consuming packages from another repository (e.g., oi consuming livestore):
// In oi/package.json:
"@livestore/sync-cf": "workspace:*"Requires per-package pnpm-workspace.yaml:
# In oi/pnpm-workspace.yaml:
packages:
- .
- ../../repos/livestore/packages/@livestore/*Why workspace:* instead of file::
The file: protocol breaks when the referenced package has internal link: dependencies:
- pnpm copies
file:deps into.pnpm/directory - Internal
link:../siblingpaths get reinterpreted relative to.pnpm/location - Symlinks resolve to wrong directories (e.g.,
flakes/commoninstead oflivestore/packages/@livestore/common)
With workspace:*:
- pnpm creates direct symlinks to source packages (not copies in
.pnpm/) - Internal
link:deps resolve correctly relative to original source location - Each package maintains its own dependency resolution
Where we use workspace:*:
- livestore: All
@livestore/*and@local/*packages (viagenie/repo.ts) - oi: References to
@livestore/*packages (viagenie/internal.ts) - Any cross-repo consumption scenario
Per-package workspace pattern:
Each package has its own pnpm-workspace.yaml including siblings:
# In livestore/packages/@livestore/sync-cf/pnpm-workspace.yaml:
packages:
- .
- ../*This enables:
- No monorepo root workspace needed
- Each package is self-contained
- External consumers include packages in their own workspace
Some locations still use file: (docs, tests, examples that need to behave like external consumers). For these, we use pnpm's experimental enableGlobalVirtualStore:
npm_config_enable_global_virtual_store=true pnpm installWhy this works: Instead of each package having its own .pnpm directory:
# Before (different paths = TS2742 errors)
packages/a/node_modules/.pnpm/effect@3.19.14_hash1/...
packages/b/node_modules/.pnpm/effect@3.19.14_hash2/...
# After (same global path = TypeScript happy)
~/Library/pnpm/store/v10/links/effect@3.19.14/...
Requirements:
- pnpm 10.12.1+ (we use 10.28.0)
- Must set via env var because pnpm auto-disables in CI-like environments
We're using pnpm temporarily due to bun bugs (see bun-issues.md). Once these are fixed, we plan to switch back to bun:
- bun's
file:protocol already works like pnpm'slink:(symlinks with own deps) - bun is significantly faster for installs
- No need for
enableGlobalVirtualStoreworkaround with bun
Tracked bun issues:
Note: The workspace:* pattern may need adaptation for bun, which has different workspace semantics.
These are generally fixed by the above solutions, but documented for reference:
-
TS7016 - Missing declaration files
error TS7016: Could not find a declaration file for module '@example/wa-sqlite/dist/file.mjs'. -
TS2688 - Missing type definitions
error TS2688: Cannot find type definition file for 'bun'. -
TS2300 - Duplicate identifiers
error TS2300: Duplicate identifier 'TODO'.
Status: KNOWN BUG - pnpm#10232 - No fix yet
Tracked in: pnpm#9696 (GVS improvements)
When running multiple pnpm install commands in parallel with enableGlobalVirtualStore, race conditions corrupt the global pnpm store. This is a known, open bug in pnpm.
Root cause: The pnpm module creates separate tasks (pnpm:install:<name>) for each package, all running concurrently after genie:run. When multiple pnpm processes try to create hardlinks/symlinks in the same global store (~/Library/pnpm/store/v10/links/), they race to rename temp directories.
Note: enableGlobalVirtualStore is marked experimental in pnpm docs. pnpm auto-disables it in CI environments partly because of these stability issues.
Symptoms:
ERR_PNPM_ENOTEMPTY ENOTEMPTY: directory not empty, rename
'~/Library/pnpm/store/v10/links/@opentelemetry/api/1.9.0/.../node_modules/@opentelemetry/api_tmp_62863'
-> '~/Library/pnpm/store/v10/links/@opentelemetry/api/1.9.0/.../node_modules/@opentelemetry/api'
The temp directory PIDs (62863, 62892, etc.) show different processes racing on the same paths.
Why it happens:
- Process A creates
@opentelemetry/api_tmp_62863 - Process B creates
@opentelemetry/api_tmp_62892 - Process A tries to rename its temp dir to
@opentelemetry/api - Process B already renamed its temp dir there
- Process A fails with
ENOTEMPTY
Using enableGlobalVirtualStore makes this worse because even more paths are shared in the global store. The traditional per-project virtual store (node_modules/.pnpm) handles concurrent access more safely.
Potential fixes:
- Disable GVS for parallel installs - If using
link:protocol for internal deps, GVS may not be needed - Single root install (recommended) - Run
pnpm installonce at workspace root withpnpm-workspace.yaml. This is pnpm's intended monorepo workflow. - Serialize installs - Chain tasks so only one runs at a time (slow but safe)
- Use
--frozen-lockfile- Reduces store writes but doesn't fully prevent races
Recovery from corrupted store:
# Clean the corrupted links directory
rm -rf ~/Library/pnpm/store/v10/links
# Then reinstall
pnpm installStatus: SOLVED - Use TypeScript
pathsmapping to redirectreactresolutionWorkaround: Add
pathsmapping in tsconfig to pointreactto the project's@types/react
When using enableGlobalVirtualStore, TypeScript fails to infer types for callback parameters in libraries like react-aria-components, resulting in TS7006: Parameter implicitly has an 'any' type errors.
Symptoms:
LiteralField.tsx(74,33): error TS7006: Parameter 'keys' implicitly has an 'any' type.
LiteralField.tsx(101,27): error TS7006: Parameter 'key' implicitly has an 'any' type.
NumberField.tsx(98,18): error TS7006: Parameter 'v' implicitly has an 'any' type.
On code like:
<ToggleButtonGroup onSelectionChange={(keys) => ...}> // keys is 'any'
<Select onSelectionChange={(key) => ...}> // key is 'any'
<AriaNumberField onChange={(v) => ...}> // v is 'any'Root cause: When enableGlobalVirtualStore is enabled, pnpm symlinks packages to the global store (~/Library/pnpm/store/v10/links/...) instead of the local .pnpm directory. This breaks TypeScript's type resolution because:
- TypeScript resolves
react-aria-componentsto its real path in the global store - When
react-aria-componentstypes need to resolvereacttypes, TypeScript walks up the directory tree - Since the real path is in the global pnpm store, it can't find
@types/react(which exists in the project'snode_modules/@types) - So
react-aria-componentsresolvesreactas a JS file without types - This causes callback parameters to lose type inference (become
any)
Comparison of type resolution:
| Config | virtualStoreDir |
react-aria-components resolves react to | Type inference |
|---|---|---|---|
| Local (default) | .pnpm |
project/node_modules/@types/react |
Works |
Global (enableGlobalVirtualStore) |
~/Library/pnpm/store/v10/links |
~/Library/.../react/index.js (no types) |
Broken |
You can verify this with --traceResolution:
tsc --build --traceResolution 2>&1 | grep -A30 "Resolving module 'react' from.*react-aria-components"With global virtual store, you'll see TypeScript fail to find @types/react and fall back to the JS file:
Directory '~/Library/pnpm/store/v10/links/@/react-aria-components/.../node_modules/@types' does not exist
...
Module name 'react' was successfully resolved to '~/Library/.../react/index.js' # JS, not .d.ts!
Potential fixes:
- Add explicit type annotations - Works but verbose and defeats the purpose of inference
- Disable
enableGlobalVirtualStore- But then you get TS2742 errors from the first issue above - Add
typeRootsin tsconfig - Doesn't help because the resolution happens from within the library's types - Ensure
@types/reactis symlinked alongsidereact-aria-components- Not easily configurable in pnpm - Use TypeScript
pathsmapping - WORKS! Redirectsreactresolution to project's@types/react
Solution: Use paths mapping in tsconfig
Add the following to your tsconfig.json:
{
"compilerOptions": {
"paths": {
"react": ["./node_modules/@types/react"]
}
}
}Note: baseUrl is not required for paths in TypeScript 5.x+.
This tells TypeScript to resolve react imports (including those from react-aria-components types) to the project's @types/react instead of walking up from the global store.
Why this works: The paths mapping is applied globally to all type resolution, including resolution happening within dependency type definitions. So when react-aria-components/dist/types.d.ts imports react, TypeScript uses the path mapping instead of node module resolution, finding @types/react in the project.
Note: This requires @types/react to be installed as a dependency in the package.
The same underlying issue affects packages that use jsxImportSource (custom JSX runtimes). When using @opentui/react or similar packages with jsxImportSource, the key prop is missing from IntrinsicAttributes:
Symptoms:
error TS2322: Type '{ key: string; name: string; }' is not assignable to type 'IntrinsicAttributes & ItemProps'.
Property 'key' does not exist on type 'IntrinsicAttributes & ItemProps'.
Root cause: The custom JSX package's type definitions extend React.Attributes for IntrinsicAttributes:
// In @opentui/react/jsx-namespace.d.ts
interface IntrinsicAttributes extends React.Attributes {}When TypeScript can't resolve React.Attributes from the global store (same issue as above), the key prop is lost.
Solution: Module augmentation
Create a .d.ts file that augments the JSX runtime module:
// src/opentui-jsx-fix.d.ts
import type { Key } from 'react'
declare module '@opentui/react/jsx-runtime' {
namespace JSX {
interface IntrinsicAttributes {
key?: Key | null | undefined
}
}
}This directly adds the key prop to the JSX namespace, bypassing the broken React.Attributes resolution.
Note: The paths workaround for react doesn't help here because jsxImportSource resolution works differently - TypeScript resolves the JSX runtime from the package location, not using standard module resolution.
Status: DOCUMENTED - Important consideration when switching back to bun
When using pnpm's link: protocol for internal packages, patches must be configured in the package that directly depends on the patched dependency, not in consuming packages.
With link:, each package uses its own node_modules:
# misc.schickling.dev depends on @overeng/utils via link:
misc.schickling.dev/
node_modules/
@overeng/utils -> ../../repos/effect-utils/packages/@overeng/utils # symlink
# NO effect-distributed-lock here!
@overeng/utils/
node_modules/
effect-distributed-lock/ # installed here, with patch applied
Key behaviors:
effect-distributed-lockis installed in@overeng/utils/node_modules/, not in consuming packages- Only
@overeng/utilsneedspnpm.patchedDependenciesconfigured - pnpm errors if you add
patchedDependenciesfor packages not in your dependency tree:ERR_PNPM_UNUSED_PATCH The following patches were not used: effect-distributed-lock@0.0.11
This behavior may differ with bun. Bun might:
- Hoist transitive dependencies into consuming packages'
node_modules - Use different
file:/link:semantics - Require
patchedDependencies(top-level, not underpnpm) in all consuming packages
When switching back to bun, verify:
- Where
effect-distributed-lockgets installed (check each package'snode_modules) - Whether the patch from
@overeng/utilsapplies transitively - If not, add
patchedDependenciesto all packages that use@overeng/utils:
{
"patchedDependencies": {
"effect-distributed-lock@0.0.11": "path/to/patches/effect-distributed-lock@0.0.11.patch"
}
}Testing the patch:
# Check if RedisBacking export is removed (patch applied)
grep "export.*RedisBacking" node_modules/effect-distributed-lock/dist/index.js
# Should return nothing if patch is applied- Only
@overeng/utilshaspnpm.patchedDependencies - All other packages removed both
postinstallscripts ANDpatchedDependencies - Consuming packages get the patched version through the
link:symlink
- Detailed comparison gist: https://gist.github.com/schickling/d05fe50fe4ffb1c2e9e48c8623579d7e
- pnpm docs on
file:protocol: https://pnpm.io/package_json#dependencies - pnpm docs on workspaces: https://pnpm.io/workspaces
- pnpm
enableGlobalVirtualStoredocs: https://pnpm.io/npmrc#enableglobalvirtualstore - pnpm#10232 - ERR_PNPM_ENOTEMPTY for concurrent pnpm install with GVS
- pnpm#9696 - Improvements to global virtual store (tracking issue)