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
2
./app/(marketing)/[locale]/docs/[[...path]]/page.tsx:26:19
Type error: Property 'toc' does not exist on type 'PageData'.

page.data 应该是包含 tocbodystructuredData 等字段的完整文档类型,但 TypeScript 将它推导为只有 titledescriptionicon 的基础 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
2
3
4
5
content-collections.ts
→ transformMDX 返回 D & { toc, body, structuredData }
→ GetTypeByName<typeof configuration, "docs"> = Doc(应含 toc)
→ createMDXSource(allDocs, allDocsMetas)
→ loader() → page.data = Doc

如果这条链上任何一环类型推导失败,page.data 就会退化为基础的 PageData

第三轮:本地 tsc 的假象

在本地执行 npx tsc --noEmit --diagnostics,关键指标:

1
2
3
Types:              85
Instantiations: 0
Check time: 0.00s ← 根本没做类型检查!

对比服务器:

1
2
3
Types:            258896
Instantiations: 1014830
Check time: 14.57s ← 完整检查

本地 Check time: 0.00s 意味着 TypeScript 使用了 增量编译缓存tsconfig.tsbuildinfo),直接跳过了类型检查。这就是本地”看起来没问题”的原因。

删掉缓存文件重新检查:

1
2
rm apps/web/tsconfig.tsbuildinfo apps/web/.next/cache/.tsbuildinfo
npx tsc --noEmit --diagnostics

这次本地也做了完整检查(26s),但仍然通过了——说明不是缓存问题,是 node_modules 内部结构 的差异。

第四轮:真正的突破——tsc 完整错误输出

在服务器上执行 npx tsc --noEmit,除了 page.data.toc 错误,还暴露了上游错误

1
2
3
4
5
6
7
8
content-collections.ts:107:19 - error TS2345:
Argument of type '{ title: ZodOptional<ZodString>; ... }'
is not assignable to parameter of type 'ZodRawShape'.
Property 'title' is incompatible with index signature.
Type 'ZodOptional<ZodString>' is missing the following properties
from type 'ZodType<any, any, any>': _type, _parse, _getType, ...

107 schema: z.object(createMetaSchema(z)),

这才是源头! content-collections.ts 本身就有类型错误,导致整条推导链崩塌。


根因

直接原因

content-collections.ts 中的代码:

1
2
3
4
5
6
7
import { z } from "zod";                                    // ← 项目的 Zod
import { createDocSchema, createMetaSchema } from "@fumadocs/content-collections/configuration"; // ← fumadocs 的 Zod

const docs = defineCollection({
schema: z.object(createDocSchema(z)), // 💥 两个 Zod 实例的类型不兼容
// ...
});

createDocSchema(z) 返回的 ZodOptional<ZodString> 类型来自 fumadocs 包解析到的 Zod 类型声明,而 z.object() 期望的 ZodRawShape 来自项目自身的 Zod 类型声明。当这两个指向不同的类型声明文件时,TypeScript 认为它们不兼容。

根本原因:pnpm 的模块解析差异

pnpm 使用严格的依赖隔离机制。每个包只能访问自己声明的依赖,通过 .pnpm 目录中的符号链接实现。

本地环境:pnpm 的 hoist 策略恰好让 @fumadocs/content-collections 和项目代码解析到同一个 Zod 类型文件:

1
2
3
@fumadocs/content-collections → resolve('zod') → node_modules/.pnpm/zod@3.25.76/.../zod
项目代码 → resolve('zod') → node_modules/.pnpm/zod@3.25.76/.../zod
↑ 同一个文件,TypeScript 认为类型兼容

服务器环境:pnpm 的 hoist 结果不同(可能受 .npmrc 配置、安装顺序、缓存状态等影响),两者解析到不同路径的 Zod 类型文件:

1
2
3
@fumadocs/content-collections → resolve('zod') → .pnpm/.../node_modules/zod  (副本 A)
项目代码 → resolve('zod') → .pnpm/.../node_modules/zod (副本 B)
↑ 虽然版本相同,但 TypeScript 视为不同类型

TypeScript 的结构化类型系统在这里失效了——因为 Zod 的内部类型使用了 private/# 字段(如 _type_parse),这些 标称类型 在不同类型声明文件间不兼容。

因果链

1
2
3
4
5
6
7
8
pnpm hoist 差异
→ fumadocs 和项目代码解析到不同的 Zod 类型声明文件
→ createDocSchema(z) 返回的类型与 z.object() 期望的类型不兼容
→ content-collections.ts 产生类型错误
→ TypeScript 无法推导 Doc 类型(退化为 PageData)
→ GetTypeByName 失败
→ page.data.toc 不存在
→ 构建失败

解决方案

治本方案:消除跨包 Zod 类型依赖

不再使用 fumadocs 的 createDocSchema(z) / createMetaSchema(z) 辅助函数,改为用项目自身的 z 实例内联定义完全等价的 schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ❌ 修改前:跨包传递 Zod 实例
import { createDocSchema, createMetaSchema, transformMDX } from "@fumadocs/content-collections/configuration";

const docs = defineCollection({
schema: z.object(createDocSchema(z)), // fumadocs 的 Zod 类型 vs 项目的 Zod 类型
// ...
});

// ✅ 修改后:所有 schema 用同一个 z 实例定义
import { transformMDX } from "@fumadocs/content-collections/configuration";

const docs = defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
full: z.boolean().optional(),
_openapi: z.record(z.string(), z.any()).optional(),
}),
transform: async (document, context) => transformMDX(document, context, { /* ... */ }),
});

const docsMeta = defineCollection({
schema: z.object({
title: z.string().optional(),
description: z.string().optional(),
pages: z.array(z.string()).optional(),
icon: z.string().optional(),
root: z.boolean().optional(),
defaultOpen: z.boolean().optional(),
}),
});

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
2
3
4
5
6
7
1. 对比版本号(Node、TS、关键依赖)
2. 对比生成文件内容(.d.ts、generated/)
3. 对比 tsconfig 配置
4. 清除本地缓存后完整检查(删 .tsbuildinfo)
5. 用 tsc --noEmit 查看完整错误(不止报第一个)
6. 用 tsc --diagnostics 对比 Types/Instantiations/Check time
7. 检查 node_modules 的实际解析路径(realpath + pnpm 符号链接)

第 6 步是关键转折点——Check time: 0.00s 立刻暴露了本地根本没在做类型检查这个事实。


总结

一个看似简单的 Property 'toc' does not exist on type 'PageData' 错误,背后是 pnpm 依赖隔离 × Zod 标称类型 × TypeScript 增量编译缓存 三重因素叠加的结果。类型断言(as any)只是止痛药,真正的治疗是消除跨包类型构造器的传递依赖。