【monorepo疑难症】pnpm不是依赖隔离吗?怎么类型还是串味了?

问题背景

monorepo项目迁移时,顺便把依赖的包 task-sdk 和 eslint-plugin-promotion 也一起迁移了,但是在一个完全不相关的项目里出了react上游类型的报错。

解决方案

CC老师半小时完美解决问题

根本原因是 packages/task-sdk 这个子包的的依赖和全局版本不一样,导致安装了两个 @types/react 版本,在TS类型解析时出现了冲突,而 18.0 -> 18.3 正好有一个 FC类型的BREAK CHANGE,导致报错。

TSC行为深挖

问题是解决了,但是我心中的疑问没有消散:为什么理论上pnpm workspace隔离的依赖,会让两个完全不相关的子包的类型相互影响呢?

从代码和行为找根因

按理说 pnpm 的设计初衷就是为了物理隔离(严格的依赖树),A 包不应该能“看见” B 包的依赖。但为什么 LSP(Language Server Protocol,VS Code 里的 TS 服务)还是“偷看”到了呢?
  1. 向上查找机制 (Node Resolution Algorithm): 如果在当前目录找不到,就往上级目录找

    1. pnpm在monorepo内处理依赖是通过软链接实现的,所有的依赖都会平铺在根目录的 node_modules/.pnpm 下,在子包中通过软链接指向这些根目录的依赖
    2. 举例一个场景:子包 B (新) 需要 @types/[email protected],而子包 A (旧) 需要 @types/[email protected],TSC就会同时加载这两个类型包到自己的上下文中
    3. 代码举证:https://github.com/microsoft/TypeScript/blob/9d364978d3e8e4de01bb2bec8083b58c1f710232/src/compiler/moduleNameResolver.ts#L810
  2. 符号链接穿透 (Symlink Preservation):pnpm 使用大量的软链接(Symlinks)来管理依赖

    1. TypeScript 编译器(以及 LSP)默认会解析软链的真实路径 (realpath),这意味着,虽然在你的文件树里它们看起来都在各自的 node_modules 里,但在 TS 的眼里,它实际上加载了两个物理路径完全不同的文件
  3. 全局命名空间的“污染” (The Fatal Blow): 这是最根本的原因。React 的类型定义(特别是 JSX)是全局的 (Global Namespace)

    一旦加载,declare global { namespace JSX } 这段代码就会生效。全局命名空间就像一个公共的游泳池,谁往里面撒尿,所有人都会受影响。
    1. React中JSX命名空间的代码都是全局的(global下),这导致你如果因为原因1加载了两个不同的版本类型包,TS就会尝试帮你执行合并
    2. TSC的合并策略:能合则合,不行则挂

      • 如果两个来源定义的类型是可以合并的(主要是 ⁠interface 和 ⁠namespace),且没有属性冲突,TypeScript 会自动把它们拼在一起。这是 TS 的一个强大特性,允许你扩展现有的类型(比如扩展 Window)
      • 如果两个来源试图定义同一个事物,但定义不兼容,或者该类型不支持合并,TS 就会直接报错。

综上所述,由于TSC的行为问题,在monorepo中类型依赖(@types/*)很难做到完成隔离,因此重要依赖如PIA, EdenX, React等尽可能保持完全一致,如果特殊情况需要保持独立,则需要付出很大的配置代价。

一个简单的复现实验

创建一个示例monorepo来复现「全局类型污染」

pnpm-pollution-demo
├── node_modules
│   └── typescript -> .pnpm/[email protected]/node_modules/typescript
├── package.json
├── packages
│   ├── attacker
│   │   ├── tsconfig.json
│   │   └── types.d.ts
│   └── victim
│       ├── index.ts
│       └── tsconfig.json
├── tsconfig.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml

代码很简单:

// pnpm-pollution-demo/packages/victim/index.ts

// This file does NOT import anything.
// Standard TypeScript should error on 'window.secretToken' because it doesn't exist on Window.
console.log("Token is:", window.secretToken);

// pnpm-pollution-demo/packages/attacker

// This file defines a global augmentation.
// It is NOT imported by the victim package.
declare global {
  interface Window {
    secretToken: string;
  }
}
export {};

上面的代码在:

  • 只有根目录的 tsconfig.json 存在时,tsc通过:因为整个monorepo都是一个类型上下文,victim能够读取到attacker的类型
  • 每个package有独立的tsconfig.json时,tsc失败victim具有独立的ts上下文,无法读取到外部类型了

而react这种在node_modules/@types里的依赖会更特殊一点,只要读取到了就会始终加载,且JSX是全局类型,因此污染更严重。

全局类型声明到底从哪来

tsconfig中的types与typeRoots

默认情况下,tsc会读取并加载 node_modules/@types 及其嵌套的所有上层目录(直至/)中的所有ts文件,而这两个选项就可以控制该行为:

  • types :指定上述目录中扫描到的所有包中哪些包名可以被加载到全局上下文中,取值举例如["react", "express"]

    • 注意1: 这个选项并不会限制「寻找包的目录范围」,只会决定哪些包里的ts文件会被tsc执行
    • 注意2: types包是否被执行只会影响全局定义,不会影响import的类型,但是这会影响IDE中LSP的Auto Import行为(因为tsc执行上下文里没有这个包,就没法为你提示)
  • typeRoots:指定会在哪些目录里搜寻类型定义并自动执行这些文件,默认为node_modules/@types及其所有嵌套的上层目录,修改后就只会在指定目录中读取并加载,注意这会覆盖掉默认值,而不是合并

tsconfig的读取顺序

  • 简单理解:每个tsconfig.json对应了一个TS执行上下文,可以通过projectReference来串联不同的执行上下文
  • 当上下文发生嵌套时,取决于你要读取的文件的层级上最新的 tsconfig.json 的配置,tsc和IDE LSP行为一致

全局作用域 vs 模块作用域

在不写任何模块语句时,ts和dts都是默认全局作用域的。
但是即使在模块作用域里,也可以使用
declare global 来声明全局类型,只要这个ts文件在tsconfig的执行上下文(include)范围内。

// test.ts
declare global {
  const BUILD_TYPE: string;
}

export {}; // this is a module

// index.ts
console.log(BUILD_TYPE); // here BUILD_TYPE is a global var in type space

这里使用了两个神秘关键字:

  • declare:相当于告诉tsc「信我,这里有这个东西」,一般用于描述当前上下文中已有变量的类型,比如DOM、BOM API等,后面一般能接下面的类型

    • JS里的变量,如declare var a , declare function b
    • 模块/命名空间,如 declare module 'react' {} 就可以声明react里有什么东西,一般常用于给没有类型的包补充类型,或者给资源添加类型(declare module '*.scss' { export default = any}
    • 类型,如 declare interface A,这种case下使用和不使用declare的效果是完全一样的
  • global:一个特殊的「模块」,代表着当前运行时的全局作用域,在浏览器中为window,在Node环境中为globalThis

因此declare global {} 相当于把{}内的代码的作用域切换到了全局,因此在模块作用域里也能生效。

dts的外部类型写法

dts中有三种写法来引用外部类型:

  • 传统import:这会让dts成为模块化的dts,通常只用于包的类型声明,无法声明全局类型

    // demo.d.ts
    import React from "react"
    
    type FC = React.FC
  • 类型import:内联导入类型,可以在不破坏全局作用域的情况下引用类型

    // demo.d.ts
    type FC = import('react').FC
  • 三斜杠指令:类似C语言里的#include,告诉TS在执行这个文件前需要先执行引用的文件,就像宏展开

    ///<reference path="..."/> 告诉编译器“编译我的时候,必须先把那个文件也编译了”
    ///<reference types="..."/> 告诉编译器“这个文件依赖于某个包的全局类型”。
【monorepo疑难症】pnpm不是依赖隔离吗?怎么类型还是串味了?
https://chlorinec.top/posts/global-types-in-monorepo/
作者
ChlorineC
发布于
2026-01-09
许可协议
CC BY-NC-SA 4.0