Monorepo 与类型共享

Monorepo 与类型共享

写在前面

这是 TypeScript 系列的最后一篇。当公司有多个 TypeScript 项目,类型怎么共享?——是 monorepo 内部直接引用,还是发布到 npm 共享?怎么让所有项目都能用上最新的类型?

本篇会讲清楚 monorepo 的类型共享、TypeScript Project References、dts 产物生成、版本发布流程,以及公司内部 SDK 的完整类型发布实战。

一、Monorepo 中的类型共享模式

1.1 三种模式

模式 适用 工具
workspace 直接引用 内部项目 pnpm/yarn workspace
构建产物引用 monorepo 内的 SDK/UI 库 tsc -b / 打包器
发布到 npm 跨 monorepo 或对外 npm publish + changesets

1.2 项目结构

monorepo/
├── pnpm-workspace.yaml
├── tsconfig.base.json     # 共享 TS 配置
├── packages/
│   ├── ui-library/        # UI 组件库(发布到 npm)
│   ├── sdk/               # 内部 SDK(发布到内部 registry)
│   └── config/            # 共享配置(eslint, tsconfig)
└── apps/
    ├── web-app/
    └── admin-app/

二、workspace 直接引用

2.1 pnpm-workspace 配置

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

2.2 直接引用

// apps/web-app/package.json
{
    "name": "web-app",
    "dependencies": {
        "@internal/sdk": "workspace:*",
        "@internal/ui-library": "workspace:*"
    }
}
// apps/web-app/src/index.ts
import { createUser } from "@internal/sdk";  // 直接用本地包
import { Button } from "@internal/ui-library";

优点:简单,改代码立刻生效,不需要发布 缺点:所有项目必须 monorepo,不能跨 monorepo 用

2.3 类型同步

直接引用 workspace 时,类型也是实时同步的——SDK 改了类型,引用方立刻看到。

三、TypeScript Project References

3.1 基础概念

Project References 把大型 TS 项目拆成多个子项目,每个子项目独立编译,产生独立的 .d.ts 和 .js。

packages/sdk/
├── tsconfig.json
├── src/
│   └── index.ts
└── dist/
    ├── index.js         # 编译产物
    ├── index.d.ts       # 类型声明
    └── tsconfig.tsbuildinfo

3.2 子项目 tsconfig

// packages/sdk/tsconfig.json
{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "composite": true,            // 必须 true
        "declaration": true,          // 必须生成 .d.ts
        "declarationMap": true,       // .d.ts.map 便于跳转
        "outDir": "./dist",
        "rootDir": "./src",
        "tsBuildInfoFile": "./dist/.tsbuildinfo"
    },
    "include": ["src/**/*"]
}

3.3 根 tsconfig

// tsconfig.json
{
    "files": [],
    "references": [
        { "path": "./packages/sdk" },
        { "path": "./packages/ui-library" },
        { "path": "./apps/web-app" }
    ]
}

3.4 编译

# 编译所有子项目
tsc --build

# 增量编译(只编改过的)
tsc --build --watch

# 强制全量
tsc --build --force

3.5 实战:跨包类型

// packages/sdk/src/index.ts
export interface User {
    id: string;
    name: string;
    email: string;
}

export async function createUser(input: Omit<User, "id">): Promise<User> {
    // ...
}
// apps/web-app/src/index.ts
import { createUser, type User } from "@internal/sdk";

async function main() {
    // 完整类型补全
    const user: User = await createUser({ name: "Alice", email: "a@x.com" });
    console.log(user.id);
}

四、打包发布物

4.1 三种发布格式

格式 文件后缀 适用
ESM .mjs / .js(ESM) 现代打包器(Vite / Webpack 5 / Rspack)
CJS .cjs / .js(CJS) Node.js / 老 webpack
TypeScript .ts 内部使用

4.2 package.json 配置

{
    "name": "@internal/sdk",
    "version": "1.2.3",
    "main": "./dist/index.cjs",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts",
    "exports": {
        ".": {
            "import": {
                "types": "./dist/index.d.mts",
                "default": "./dist/index.mjs"
            },
            "require": {
                "types": "./dist/index.d.cts",
                "default": "./dist/index.cjs"
            }
        }
    },
    "files": ["dist"],
    "scripts": {
        "build": "tsc && tsc -p tsconfig.esm.json && node scripts/build.js",
        "prepublishOnly": "npm run build && npm test"
    }
}

4.3 双构建配置

CJS 配置

// packages/sdk/tsconfig.json (默认)
{
    "extends": "../../tsconfig.base.json",
    "compilerOptions": {
        "composite": true,
        "declaration": true,
        "declarationMap": true,
        "module": "CommonJS",
        "moduleResolution": "Node",
        "outDir": "./dist",
        "rootDir": "./src"
    },
    "include": ["src/**/*"]
}

ESM 配置

// packages/sdk/tsconfig.esm.json
{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "module": "ESNext",
        "moduleResolution": "Bundler",
        "outDir": "./dist/esm",
        "tsBuildInfoFile": "./dist/.tsbuildinfo.esm"
    }
}

4.4 实战:用 tsup 简化发布

tsup 是基于 esbuild 的打包工具,内置 DTS 生成:

// packages/sdk/tsup.config.ts
import { defineConfig } from "tsup";

export default defineConfig({
    entry: ["src/index.ts"],
    format: ["esm", "cjs"],
    dts: true,           // 自动生成 .d.ts
    sourcemap: true,
    clean: true,
    splitting: false,    // 库打包通常不分割
    treeshake: true,
});
// package.json
{
    "scripts": {
        "build": "tsup",
        "dev": "tsup --watch"
    }
}

一行命令出 ESM + CJS + 类型声明——比手写 tsc -b 简单太多。

五、内部 npm registry

5.1 为什么需要内部 registry

公司 SDK 不能发到 npmjs.com(安全 / 速度 / 私密)——需要内部 npm registry。

5.2 选型

工具 特点
Verdaccio 轻量级,Node.js,适合中小团队
Nexus Repository 功能多,适合大型企业
Artifactory 通用制品管理,JFrog 出品
GitHub Packages GitHub 内置,免费额度够用

5.3 Verdaccio 部署

# 安装
npm install -g verdaccio

# 启动
verdaccio

# 默认 4873 端口
# verdaccio/config.yaml
storage: /var/lib/verdaccio/storage
auth:
  htpasswd:
    file: /var/lib/verdaccio/htpasswd

packages:
  "@internal/*":
    access: $authenticated
    publish: $authenticated
    proxy: npmjs

uplinks:
  npmjs:
    url: https://registry.npmjs.org/

listen: 0.0.0.0:4873

5.4 发布

# 登录内部 registry
npm login --registry=http://npm.internal.company.com

# 发布
npm publish --registry=http://npm.internal.company.com

5.5 使用方配置

# 设置内部 registry 作为默认
npm config set registry http://npm.internal.company.com

# 或者项目级 .npmrc
# .npmrc
registry=http://npm.internal.company.com/
@internal:registry=http://npm.internal.company.com
@myorg:registry=http://npm.internal.company.com

只在 @internal/* 和 @myorg/* 走内部 registry——其他包走 npmjs。

六、版本管理 - Changesets

6.1 为什么需要 Changesets

monorepo 有多个包,改一个会影响多个。Changesets 帮你自动:

  1. 让开发者描述改动
  2. 自动决定哪些包需要升级,什么版本
  3. 自动生成 CHANGELOG
  4. 自动提交 commit

6.2 安装

pnpm add -D -w @changesets/cli
pnpm changeset init

6.3 添加 changeset

pnpm changeset

交互式选择:

# 哪些包变化了?
🐶 @internal/sdk (minor)
🦊 @internal/ui-library (patch)

# 描述
"新增 Button loading 状态,优化错误处理"

生成 .changeset/happy-dogs-sing.md:

---
"@internal/sdk": minor
"@internal/ui-library": patch
---

新增 Button loading 状态,优化错误处理

6.4 合并 PR

CI 检测到 changeset 后:

pnpm changeset version  # 自动升级版本 + 生成 CHANGELOG

生成:

<!-- packages/sdk/CHANGELOG.md -->

## 1.2.0

### Minor Changes

- 新增 Button loading 状态,优化错误处理

6.5 发布

pnpm changeset publish  # 发布所有有 changeset 的包

全自动化——开发者只关心写 changeset。

6.6 CI 集成

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://npm.internal.company.com
      
      - run: pnpm install --frozen-lockfile
      
      - name: Create release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          pnpm changeset version
          pnpm changeset publish
          git push --follow-tags          

七、类型版本管理

7.1 类型版本独立

库的代码变了,但类型不变,要不要发 minor?

原则:类型变更 = minor / major(因为影响用户代码),只是改 bug = patch(如果类型不变)。

7.2 标记类型变更

// @internal/sdk CHANGELOG.md

## 1.2.0

### Minor Changes

- `getUser` 返回类型从 `User | null` 改为 `User | undefined`(修复 null 误用)
- 新增 `User.id` 字段类型为 `string`(原来是 `string | number`)

7.3 类型测试

类型也应该测试——验证类型推断正确。

// packages/sdk/__tests__/types.test-d.ts
import { expectType, expectError } from "tsd";
import { createUser, type User } from "../src/index";

expectType<Promise<User>>(
    createUser({ name: "Alice", email: "a@x.com" })
);

expectError(
    createUser({ name: "Alice" })  // 缺 email
);
// package.json
{
    "scripts": {
        "test": "tsd"
    }
}

tsd 库专门做类型测试。

八、实战:内部 SDK 完整发布流程

8.1 SDK 项目结构

packages/sdk/
├── src/
│   ├── index.ts
│   ├── users.ts
│   └── orders.ts
├── __tests__/
│   ├── users.test.ts
│   └── types.test-d.ts
├── CHANGELOG.md
├── package.json
├── tsup.config.ts
└── tsconfig.json

8.2 package.json

{
    "name": "@internal/sdk",
    "version": "1.0.0",
    "main": "./dist/index.js",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts",
    "exports": {
        ".": {
            "import": {
                "types": "./dist/index.d.mts",
                "default": "./dist/index.mjs"
            },
            "require": {
                "types": "./dist/index.d.cts",
                "default": "./dist/index.cjs"
            }
        }
    },
    "files": ["dist"],
    "scripts": {
        "build": "tsup",
        "dev": "tsup --watch",
        "test": "vitest run",
        "test:types": "tsd",
        "lint": "eslint src --max-warnings=0",
        "prepublishOnly": "npm run lint && npm run test && npm run build && npm run test:types"
    },
    "devDependencies": {
        "tsup": "^8.0.0",
        "tsd": "^0.31.0",
        "vitest": "^1.6.0",
        "typescript": "^5.3.0"
    },
    "publishConfig": {
        "registry": "http://npm.internal.company.com"
    }
}

8.3 完整的发布流程

# 1. 开发
git checkout -b feature/new-api

# 2. 写代码 + 测试
vim src/users.ts
vim __tests__/users.test.ts

# 3. 跑测试
pnpm test
pnpm test:types
pnpm lint

# 4. 添加 changeset
pnpm changeset
# 选择 @internal/sdk,版本 minor
# 写描述:"新增 listUsers API"

# 5. 提交 + PR
git add .
git commit -m "feat: 新增 listUsers API"
gh pr create

# 6. Code Review + 合并
# (Reviewer 确认 changeset 描述)

# 7. CI 自动发布
# 合并到 main 后,GitHub Actions:
# - pnpm changeset version (升级版本 + 生成 CHANGELOG)
# - pnpm changeset publish (发布到内部 registry)
# - git push (提交版本变更)

8.4 使用方自动升级

# 50 个引用方项目升级 SDK 版本
pnpm update @internal/sdk --latest

# 或者用 Renovate / Dependabot 自动 PR

整个发布 + 升级流程全自动——开发者只写 changeset。

九、内部类型共享的几个最佳实践

9.1 共享配置

// packages/config/tsconfig.base.json
{
    "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "moduleResolution": "Bundler",
        "strict": true,
        "skipLibCheck": true,
        "esModuleInterop": true,
        "isolatedModules": true,
        "noEmit": true
    }
}
// apps/web-app/tsconfig.json
{
    "extends": "@internal/config/tsconfig.base.json",
    "compilerOptions": {
        "paths": {
            "@/*": ["./src/*"]
        }
    },
    "include": ["src/**/*"]
}

9.2 共享工具类型

// packages/utils-types/src/index.ts
export type AsyncState<T> =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: T }
    | { status: "error"; error: Error };

export type Result<T, E = Error> =
    | { ok: true; value: T }
    | { ok: false; error: E };

export type Nullable<T> = T | null;
export type Optional<T> = T | undefined;
// apps/web-app/package.json
{
    "dependencies": {
        "@internal/utils-types": "workspace:*"
    }
}

9.3 API 契约类型

// packages/contracts/src/index.ts
export interface User {
    id: string;
    name: string;
    email: string;
}

export interface ApiResponse<T> {
    code: 0;
    data: T;
} | {
    code: 1;
    message: string;
    fields?: string[];
}

前后端共用——后端实现按这个类型,前端按这个类型调用。

十、TS 5.x 的 monorepo 改进

10.1 isolatedDeclarations(TS 5.5+)

{
    "compilerOptions": {
        "isolatedDeclarations": true
    }
}

要求每个文件的类型可以独立推断——为未来 Go 重写编译器(不需要看 import)做准备。

// NG - isolatedDeclarations 下报错
export function process(x) {  // 隐式 any
    return x.toString();
}

// OK
export function process(x: number): string {
    return x.toString();
}

10.2 没有 isolatedDeclarations 的项目

十一、本篇小结

主题 关键点
workspace 引用 monorepo 内部最简单
Project References 大型 monorepo 必备
打包发布 tsup / tsc -b + 双构建
内部 registry Verdaccio / GitHub Packages
Changesets 版本管理 + CHANGELOG
类型测试 tsd 验证类型
工具 适用
pnpm workspace monorepo 基础
tsup 库打包(快速)
tsc -b 库打包(原生)
Verdaccio 内部 registry
Changesets 版本发布
tsd 类型测试

到这里 TypeScript 进阶系列 9 篇全部完成。

系列回顾

  1. ✅ 类型系统基础与心智模型
  2. ✅ 联合类型与类型守卫
  3. ✅ 泛型与条件类型
  4. ✅ 模板字面量类型
  5. ✅ 声明文件与 .d.ts
  6. ✅ tsconfig 调优与工程配置
  7. ✅ 类型编程在业务中的实战
  8. ✅ TS 5.x 新特性深度解读
  9. ✅ Monorepo 与类型共享

到这里,四个系列全部完成。最后让我创建文章生成 Skill,让你后续可以按规范续写。

参考资料