TypeScript Project References
它解决什么问题
当 TypeScript 项目变大后,如果所有源码共用一个 tsconfig.json,通常会逐渐出现以下问题:
- 类型检查越来越慢:修改一个小文件,也可能触发整个仓库重新检查。
- 模块边界模糊:应用、组件库和基础模块可以随意互相导入,最终形成循环依赖。
- 环境类型互相污染:Node 包意外使用了
window,Web 包则依赖了process、Buffer等 Node 全局变量。 - 发布边界不清晰:不知道哪些类型和函数属于 package 的公开 API。
- 编辑器与 CI 结果不一致:本地通过,换到 CI、Node ESM 或 bundler 后出现模块解析错误。
- 改动影响范围不可控:底层 package 修改后,不容易判断应当重新构建哪些上层 package。
TypeScript Project References 通过“一个 package 对应一个 TypeScript 项目”的方式,把大型代码库拆成多个可独立检查、构建和缓存的单元。它主要解决的是编译依赖图、增量构建和类型边界问题,而不是 npm 依赖安装或运行时模块解析问题。
💡
references指tsconfig.json中的 TypeScript Project References,不是/// <reference ... />三斜线指令。
推荐结构:Monorepo + 多 package
下面以 pnpm workspace 为例:
repo/
├─ apps/
│ ├─ web/ # 浏览器应用
│ └─ server/ # Node.js 服务
├─ packages/
│ ├─ core/ # 领域模型、通用逻辑;不依赖 UI
│ └─ ui/ # UI 组件;可以依赖 core
├─ package.json
├─ pnpm-workspace.yaml
├─ tsconfig.base.json
└─ tsconfig.json # solution config,只描述项目引用图
建议约束依赖方向:
web ───────→ ui ───────→ core
│ ↑
└─────────────────────────┘
server ──────────────────→ core
这意味着:
web可以依赖ui和core。server可以依赖core,但不能依赖ui或web。ui可以依赖core。core不依赖任何具体应用,也不应使用 DOM、React 或 Node 专属 API。
这套结构解决的核心问题是:让高层应用依赖稳定的底层能力,而不是让底层模块反向依赖应用实现。
1. 配置 workspace 依赖
pnpm-workspace.yaml:
packages:
- apps/*
- packages/*
根目录 package.json:
{
"name": "typescript-monorepo",
"private": true,
"scripts": {
"typecheck": "tsc -b --pretty",
"typecheck:watch": "tsc -b -w --preserveWatchOutput",
"build:types": "tsc -b",
"clean:types": "tsc -b --clean"
},
"devDependencies": {
"typescript": "^5.9.0"
}
}
apps/web/package.json 通过 workspace 协议声明真实依赖:
{
"name": "@repo/web",
"private": true,
"dependencies": {
"@repo/core": "workspace:*",
"@repo/ui": "workspace:*"
}
}
references 和 package.json 的职责不同:
package.json.dependencies描述 包管理器和运行时依赖。tsconfig.json.references描述 TypeScript 构建依赖。
通常二者应保持一致。只写 references 并不会让 Node、Vite 或 pnpm 自动识别一个包。
2. 建立共享基础配置
根目录 tsconfig.base.json 只放各项目真正通用的选项:
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true
}
}
这些选项分别解决:
strict:打开完整的严格类型检查,减少隐式any和空值错误。noUncheckedIndexedAccess:通过索引读取数组或对象时,把“不存在”纳入类型。exactOptionalPropertyTypes:区分“属性缺失”和“属性值为undefined”。verbatimModuleSyntax:要求类型导入明确写为import type,避免类型导入产生意外运行时代码。isolatedModules:保证每个文件都能被 Vite、SWC、esbuild 等单独转译。skipLibCheck:跳过依赖包.d.ts的内部检查,缩短大型项目的检查时间;它不会跳过项目源码的检查。
不要在基础配置里统一写 lib、types、module 或 moduleResolution,因为 Web、Node 和可发布库的运行环境可能不同。
3. 配置可复用的 core package
packages/core/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": [],
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
Project References 中,被其他项目引用的项目必须开启 composite: true。它会要求项目拥有明确、可追踪的输入文件集合,并生成增量构建信息。
packages/core/src/user.ts:
export interface User {
id: string;
name: string;
}
export function formatUser(user: User): string {
return `${user.name} (${user.id})`;
}
packages/core/src/index.ts:
export type { User } from "./user.js";
export { formatUser } from "./user.js";
这里刻意通过 index.ts 暴露公共 API。上层包只从 @repo/core 导入,而不应穿透到 @repo/core/src/user。这样可以重构内部目录,而不破坏使用者。
packages/core/package.json:
{
"name": "@repo/core",
"version": "0.0.0",
"type": "module",
"files": ["dist"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
这解决了两个问题:
declaration: true生成.d.ts,上层项目通过声明文件消费类型边界。exports限制外部只能访问明确公开的入口,防止深层导入逐渐变成无法删除的隐式 API。
4. 配置依赖 core 的 UI package
packages/ui/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{ "path": "../core" }
],
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
packages/ui/src/UserBadge.tsx:
import type { User } from "@repo/core";
export interface UserBadgeProps {
user: User;
}
export function UserBadge({ user }: UserBadgeProps) {
return <span data-user-id={user.id}>{user.name}</span>;
}
import type 表达了“这个依赖只存在于类型层”。在启用 verbatimModuleSyntax 后,如果把 User 写成普通导入,TypeScript 会直接提示问题,而不是替你猜测是否应删除该导入。
5. 为 Web 和 Node 使用不同类型环境
Web 应用
apps/web/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"tsBuildInfoFile": "node_modules/.cache/web.tsbuildinfo"
},
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/ui" }
],
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}
应用的 JavaScript 由 Vite 输出,因此 TypeScript 只负责类型检查,使用 noEmit: true。引用的 core 和 ui 仍然需要产生声明文件,以形成项目边界。
浏览器项目显式声明 DOM,但不声明 node。因此下面的错误会在检查阶段暴露:
// Web 代码不应偷偷依赖 Node 全局变量。
const token = Buffer.from("secret").toString("base64");
// ^^^^^^ 找不到名称 Buffer
Node 服务
apps/server/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"],
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{ "path": "../../packages/core" }
],
"include": ["src/**/*.ts"]
}
Node 项目不包含 DOM,所以误用浏览器 API 会立即报错:
// 服务端代码不应依赖浏览器对象。
window.localStorage.setItem("token", "value");
// ^^^^^^ 找不到名称 window
将环境类型分开,可以防止“因为仓库里安装了 @types/node,所有 package 就都能使用 Node 全局类型”的隐式污染。
6. 用根 tsconfig 描述完整构建图
根目录 tsconfig.json:
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "packages/ui" },
{ "path": "apps/web" },
{ "path": "apps/server" }
]
}
这是一个 solution-style tsconfig:自身不包含源码,只聚合子项目。执行:
pnpm exec tsc -b
TypeScript 会根据引用关系自动计算顺序,而不是简单按配置中的书写顺序构建:
core → ui → web
└────────→ server
常用命令:
# 增量构建全部项目
pnpm exec tsc -b
# 持续监听;底层声明变化时重新检查受影响的上层项目
pnpm exec tsc -b -w
# 显示为什么某个项目需要或不需要重建
pnpm exec tsc -b --verbose
# 忽略缓存,强制重建
pnpm exec tsc -b --force
# 清理由 build 模式生成的产物
pnpm exec tsc -b --clean
tsc -b 会读取 .tsbuildinfo 和输出文件,只重新构建过期项目。例如只修改 server,通常不需要重新检查 ui 和 web;修改 core 的公开类型后,则会检查依赖它的上层项目。
7. 区分“源码变化”和“公开类型变化”
假设 core 的实现从循环改成 join:
export function joinNames(names: string[]): string {
return names.join(", ");
}
如果函数签名没有变化,生成的 .d.ts 也没有变化。Project References 可以避免让所有上层项目都承担无意义的完整重建成本。
如果公开签名发生变化:
// 修改前
export function findUser(id: string): User;
// 修改后
export function findUser(id: string): User | undefined;
依赖方会被重新检查,并暴露未处理的空值:
const user = findUser("42");
console.log(user.name);
// ^^^^ “user”可能为 undefined
这正是“稳定类型边界”的价值:底层 API 的破坏性变化能在编译阶段传播到所有直接或间接使用者。
8. moduleResolution: Bundler 与 NodeNext 如何选择
使用 Bundler
适合源码最终交给 Vite、Rspack、Webpack、esbuild 等工具处理的前端应用或组件源码:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
}
}
它更符合 bundler 的解析能力,通常允许源码导入时省略扩展名:
import { Button } from "./Button";
使用 NodeNext
适合 JavaScript 产物直接由 Node.js 运行的 ESM 包或服务:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
Node ESM 的相对导入通常需要在 TypeScript 源码中写最终运行时扩展名:
// 源文件是 user.ts,但编译后运行的是 user.js。
import { formatUser } from "./user.js";
moduleResolution 解决的是“TypeScript 如何理解导入”;它必须与实际运行工具保持一致。不要为了消除报错而随意切换策略。
9. 类型检查、声明生成和打包可以分工
在前端工程中,推荐让不同工具做自己擅长的事:
TypeScript / tsc -b → 类型检查、项目依赖图、.d.ts
Vite / Rspack → JavaScript 转译、打包、代码分割、资源处理
Vitest → 单元测试
Playwright → 端到端测试
例如组件库可以增加一个仅生成声明的配置 packages/ui/tsconfig.build.json:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist/types"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
JavaScript 交给 Vite library mode 打包,TypeScript 只输出类型:
vite build
pnpm exec tsc -p packages/ui/tsconfig.build.json
如果希望这个声明构建也参与 tsc -b 的依赖图,应让其他项目引用实际用于声明输出的配置,并确保输出路径不与其他配置冲突。
10. 测试配置不要污染生产构建
测试文件经常需要 Vitest、Jest 或 Node 类型,但这些类型不应自动进入生产源码。可以建立独立配置:
packages/core/tsconfig.test.json:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"types": ["vitest/globals", "node"],
"tsBuildInfoFile": "node_modules/.cache/core-test.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"references": [
{ "path": "./tsconfig.json" }
]
}
测试代码可以使用 describe、expect 和 Node API,而生产配置仍保持干净:
import { describe, expect, it } from "vitest";
import { formatUser } from "../src/index.js";
describe("formatUser", () => {
it("formats the public label", () => {
expect(formatUser({ id: "42", name: "Ada" })).toBe("Ada (42)");
});
});
11. 不要把 paths 当作运行时别名
下面的配置只会告诉 TypeScript 如何解析类型:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@core/*": ["packages/core/src/*"]
}
}
}
它通常不会重写最终 JavaScript 中的导入路径。可能出现:
TypeScript 检查通过
↓
产物仍包含 import "@core/user"
↓
Node 或 bundler 不认识该别名,运行时报错
更稳妥的跨 package 方案是:
import { formatUser } from "@repo/core";
然后同时使用:
- workspace 依赖建立包链接;
package.json.exports定义公开入口;references建立 TypeScript 构建关系。
如果确实使用 paths,必须在 Vite、测试工具和运行环境中配置完全一致的别名。
12. 约束反向依赖和深层导入
Project References 能描述正确依赖图,但不能单独阻止开发者绕过包入口直接导入源码。可以配合 ESLint:
// eslint.config.js
export default [
{
files: ["packages/core/src/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@repo/ui", "@repo/ui/*", "@repo/web", "@repo/web/*"],
"message": "core 是底层模块,不能依赖 UI 或应用层。"
}
]
}
]
}
},
{
files: ["apps/**/*.{ts,tsx}", "packages/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@repo/*/src/*"],
"message": "请通过 package 的公开 exports 导入,不要穿透 src。"
}
]
}
]
}
}
];
这类规则解决的是架构退化问题:即使某次深层导入“现在能运行”,它也会绕过公开 API,让内部重构变得困难。
13. 全局类型应集中且显式
优先使用模块导出:
// 推荐
export interface AppConfig {
apiBaseUrl: string;
}
只有扩展真实全局对象时才使用 .d.ts,并集中管理:
// apps/web/src/types/env.d.ts
export {};
declare global {
interface Window {
__APP_VERSION__: string;
}
}
对应配置必须显式包含该文件:
{
"include": ["src/**/*.ts", "src/**/*.tsx", "src/types/**/*.d.ts"]
}
export {} 很重要:它让文件成为模块,避免普通顶层声明意外进入全局作用域。
14. 常见错误与排查方式
错误一:被引用项目没有开启 composite
Referenced project must have setting "composite": true
处理:在被 references 指向的配置中开启:
{
"compilerOptions": {
"composite": true
}
}
错误二:源码文件不在 include 中
composite 项目要求实现文件能由 files 或 include 匹配。新增目录后应同步调整:
{
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
不要用过宽的 **/*,否则可能把 dist、测试快照或生成文件纳入项目。
错误三:编辑器提示引用项目尚未构建
先执行:
pnpm exec tsc -b
并确认依赖项目输出了 .d.ts。在 CI 中也应先运行 tsc -b,不要假设仓库中已经存在本地构建产物。
错误四:删除源码后仍出现旧类型
可能是 dist 或 .tsbuildinfo 残留:
pnpm exec tsc -b --clean
pnpm exec tsc -b --force
错误五:tsc 通过但运行时报找不到模块
重点检查:
package.json是否声明 workspace 依赖;exports是否包含导入的子路径;- Node ESM 是否使用正确的
.js扩展名; - bundler 是否配置了与 TypeScript 一致的别名;
- 是否错误地把
paths当作产物重写工具。
错误六:循环引用
如果 core 引用 ui,同时 ui 又引用 core,说明模块边界有问题。通常应该:
- 把双方共享的类型抽到更底层的 package;
- 通过依赖注入让底层定义接口、高层提供实现;
- 避免为了复用一个小类型而引入整个上层包。
示例:底层定义接口,而不是直接依赖 UI 实现:
// core 中定义能力边界
export interface Logger {
info(message: string): void;
}
export function createService(logger: Logger) {
return {
run() {
logger.info("service started");
}
};
}
// app 中注入具体实现
import { createService } from "@repo/core";
const service = createService({
info: (message) => console.log(message)
});
service.run();
15. CI 中的推荐检查
最小 CI 流程:
steps:
- run: pnpm install --frozen-lockfile
- run: pnpm exec tsc -b --pretty false
- run: pnpm lint
- run: pnpm test
- run: pnpm build
如果 CI 支持缓存,可以缓存 pnpm store 和 .tsbuildinfo;但必须保证缓存键包含 lockfile、TypeScript 版本和相关配置,否则可能复用错误的增量状态。
落地检查清单
- 每个逻辑边界都有独立
tsconfig.json。 - 所有被引用项目都开启
composite: true。 - 可复用 package 生成
.d.ts,并通过exports暴露公共 API。 - 根
tsconfig.json使用files: [],只聚合 references。 -
package.json.dependencies与tsconfig.references的依赖方向一致。 - Web 和 Node 项目分别声明
lib与types,避免环境污染。 - bundler 项目使用
Bundler,直接运行于 Node ESM 的项目使用NodeNext。 - 不依赖
paths单独解决运行时别名。 - 测试配置与生产配置分离。
- ESLint 阻止反向依赖和跨 package 深层导入。
- CI 使用
tsc -b验证完整依赖图。
总结
Project References 的价值不只是“编译更快”,而是把大型 TypeScript 仓库变成一张明确的项目依赖图:
composite让每个项目成为可独立缓存和构建的单元;.d.ts把 package 的公共类型变成稳定边界;references让 TypeScript 按依赖顺序增量检查;- 独立的
lib和types防止 Web、Node、测试环境相互污染; - workspace、
exports和 ESLint 补齐运行时解析与架构约束。
对于只有少量文件的小项目,这套配置可能显得偏重;当仓库中已经存在多个应用、共享组件库、Node 工具或独立发布模块时,它通常能明显降低构建成本和重构风险。