pnpm Monorepo 跨环境构建失败排查实录
pnpm Monorepo 跨环境构建失败排查实录:一个 Zod 类型幽灵导致的深坑
背景
项目 P001-OPC 是一个基于 Next.js 15 + React 19 的企业级 Monorepo 应用,使用 pnpm 管理依赖,技术栈包括:
- 构建:pnpm 10.14 + Turbo 2.5.6
- 框架:Next.js 15.5.0 / React 19.1.1 / TypeScript 5.9.2
- 文档:Fumadocs 15.7.1 + @content-collections/core 0.11.0
- 验证:Zod 3.24+
- 数据库:Prisma 6.14.0
本地开发和构建一切正常,但将代码部署到服务器执行 pnpm build 时,反复出现类型错误。
现象
本地 pnpm build 完美通过,服务器上同样的代码、同样执行 pnpm install,构建时报错:
1 | ./app/(marketing)/[locale]/docs/[[...path]]/page.tsx:26:19 |
page.data 应该是包含 toc、body、structuredData 等字段的完整文档类型,但 TypeScript 将它推导为只有 title、description、icon 的基础 PageData 类型。
排查过程
第一轮:版本对比——全部一致
在服务器上逐项检查:
| 项目 | 本地 | 服务器 |
|---|---|---|
| Node.js | v22.22.2 | v22.22.0 |
| TypeScript | 5.9.2 | 5.9.2 |
| pnpm | 10.14.0 | 10.14.0 |
| @content-collections/core | 0.11.0 | 0.11.0 |
| @fumadocs/content-collections | 1.2.1 | 1.2.1 |
| fumadocs-core | 15.7.1 | 15.7.1 |
| Zod | 3.25.76 | 3.25.76 |
所有版本完全一致。生成的 .content-collections/generated/index.d.ts 内容也相同。tsconfig.json 配置相同。
排除了版本差异。
第二轮:类型推导链分析
类型推导链路:
1 | content-collections.ts |
如果这条链上任何一环类型推导失败,page.data 就会退化为基础的 PageData。
第三轮:本地 tsc 的假象
在本地执行 npx tsc --noEmit --diagnostics,关键指标:
1 | Types: 85 |
对比服务器:
1 | Types: 258896 |
本地 Check time: 0.00s 意味着 TypeScript 使用了 增量编译缓存(tsconfig.tsbuildinfo),直接跳过了类型检查。这就是本地”看起来没问题”的原因。
删掉缓存文件重新检查:
1 | rm apps/web/tsconfig.tsbuildinfo apps/web/.next/cache/.tsbuildinfo |
这次本地也做了完整检查(26s),但仍然通过了——说明不是缓存问题,是 node_modules 内部结构 的差异。
第四轮:真正的突破——tsc 完整错误输出
在服务器上执行 npx tsc --noEmit,除了 page.data.toc 错误,还暴露了上游错误:
1 | content-collections.ts:107:19 - error TS2345: |
这才是源头! content-collections.ts 本身就有类型错误,导致整条推导链崩塌。
根因
直接原因
content-collections.ts 中的代码:
1 | import { z } from "zod"; // ← 项目的 Zod |
createDocSchema(z) 返回的 ZodOptional<ZodString> 类型来自 fumadocs 包解析到的 Zod 类型声明,而 z.object() 期望的 ZodRawShape 来自项目自身的 Zod 类型声明。当这两个指向不同的类型声明文件时,TypeScript 认为它们不兼容。
根本原因:pnpm 的模块解析差异
pnpm 使用严格的依赖隔离机制。每个包只能访问自己声明的依赖,通过 .pnpm 目录中的符号链接实现。
本地环境:pnpm 的 hoist 策略恰好让 @fumadocs/content-collections 和项目代码解析到同一个 Zod 类型文件:
1 | @fumadocs/content-collections → resolve('zod') → node_modules/.pnpm/zod@3.25.76/.../zod |
服务器环境:pnpm 的 hoist 结果不同(可能受 .npmrc 配置、安装顺序、缓存状态等影响),两者解析到不同路径的 Zod 类型文件:
1 | @fumadocs/content-collections → resolve('zod') → .pnpm/.../node_modules/zod (副本 A) |
TypeScript 的结构化类型系统在这里失效了——因为 Zod 的内部类型使用了 private/# 字段(如 _type、_parse),这些 标称类型 在不同类型声明文件间不兼容。
因果链
1 | pnpm hoist 差异 |
解决方案
治本方案:消除跨包 Zod 类型依赖
不再使用 fumadocs 的 createDocSchema(z) / createMetaSchema(z) 辅助函数,改为用项目自身的 z 实例内联定义完全等价的 schema:
1 | // ❌ 修改前:跨包传递 Zod 实例 |
Schema 内容与 fumadocs 源码完全一致,但所有 Zod 操作都发生在同一个 z 实例上,彻底消除了跨包类型依赖。transformMDX 不受影响,因为它的返回类型(D & { toc, body, structuredData })不涉及 Zod 类型。
经验教训
1. “本地没问题”不代表没问题
TypeScript 的增量编译(incremental: true)会生成 .tsbuildinfo 缓存。如果代码在某个时间点类型检查通过,后续修改可能不会触发完整重检。定期删除 tsconfig.tsbuildinfo 做全量检查是必要的。
2. pnpm 的依赖隔离是一把双刃剑
pnpm 的严格模式避免了幽灵依赖问题,但也意味着同一个包可能在不同位置存在多个副本。对于 Zod 这类使用标称类型(private 字段)的库,即使版本号完全相同,不同副本的类型声明文件在 TypeScript 看来也是不兼容的。
3. 避免跨包传递类型构造器
createDocSchema(z) 这种”把 z 传进去,返回用 z 构造的对象”的模式,在 Monorepo + pnpm 环境下天然存在隐患。**如果一个函数接受 Zod/Yup 等验证库的实例作为参数并返回用它构造的对象,就存在跨包类型不一致的风险。**更安全的做法是:
- 内联定义 schema(如本文的修复方案)
- 或由库直接导出已构建好的 schema 对象(不接受外部 z 实例)
4. 排查构建差异的方法论
当本地和服务器构建结果不一致时,排查思路:
1 | 1. 对比版本号(Node、TS、关键依赖) |
第 6 步是关键转折点——Check time: 0.00s 立刻暴露了本地根本没在做类型检查这个事实。
总结
一个看似简单的 Property 'toc' does not exist on type 'PageData' 错误,背后是 pnpm 依赖隔离 × Zod 标称类型 × TypeScript 增量编译缓存 三重因素叠加的结果。类型断言(as any)只是止痛药,真正的治疗是消除跨包类型构造器的传递依赖。




