【高级】AccessGuard v1.1:声明文件与类型发布 — TypeScript .d.ts 编写与 npm 包深度实战

【高级】AccessGuard v1.1:声明文件与类型发布 --- TypeScript .d.ts 编写与 npm 包深度实战

标签: TypeScript, 前端, IAM, 声明文件, .d.ts, npm, 权限控制, Monorepo, 类型发布

摘要 : 当 AccessGuard 从单体项目走向多包发布时,类型声明文件(.d.ts)的质量直接决定了下游消费者的开发体验。本文以 AccessGuard v1.1 的模块化发布为场景,深度拆解 TypeScript 声明文件的生成、打包、发布全链路:从 declare moduledeclare namespace 的手写声明技巧,到 rollup-plugin-dts 与 API Extractor 的声明打包策略对比,再到 package.json 中 types/exports/imports 字段的现代化配置,最终落地为 @accessguard/core@accessguard/react 的双包 Monorepo 发布方案。全文涵盖 semver 与类型 Breaking Changes 的判定标准,提供可直接复制的 CI/CD 发布流水线。


目录

  • 前言
  • 技术背景与演进逻辑
    • [1.1 JavaScript 生态的类型缺口](#1.1 JavaScript 生态的类型缺口)
    • [1.2 .d.ts 文件的设计哲学](#1.2 .d.ts 文件的设计哲学)
    • [1.3 AccessGuard 的发布需求](#1.3 AccessGuard 的发布需求)
  • 核心原理深度解析
    • [2.1 .d.ts 文件的内部结构](#2.1 .d.ts 文件的内部结构)
    • [2.2 声明空间与值空间的分离](#2.2 声明空间与值空间的分离)
    • [2.3 编译器如何消费声明文件](#2.3 编译器如何消费声明文件)
    • [2.4 Declaration Map 与源码溯源](#2.4 Declaration Map 与源码溯源)
  • 核心模块详解
    • [3.1 declare module:为无类型模块赋予类型](#3.1 declare module:为无类型模块赋予类型)
    • [3.2 declare namespace:组织全局类型空间](#3.2 declare namespace:组织全局类型空间)
    • [3.3 declare global 与模块扩充](#3.3 declare global 与模块扩充)
    • [3.4 声明打包:rollup-plugin-dts vs API Extractor](#3.4 声明打包:rollup-plugin-dts vs API Extractor)
    • [3.5 package.json 类型字段全解析](#3.5 package.json 类型字段全解析)
    • [3.6 Monorepo 双包发布:@accessguard/core + @accessguard/react](#3.6 Monorepo 双包发布:@accessguard/core + @accessguard/react)
  • 技术优缺点与适用场景
  • 实战落地
    • [5.1 项目初始化与环境配置](#5.1 项目初始化与环境配置)
    • [5.2 编写核心类型的声明文件](#5.2 编写核心类型的声明文件)
    • [5.3 配置声明打包](#5.3 配置声明打包)
    • [5.4 配置 package.json 发布字段](#5.4 配置 package.json 发布字段)
    • [5.5 CI/CD 自动发布流水线](#5.5 CI/CD 自动发布流水线)
    • [5.6 类型 Breaking Change 检测](#5.6 类型 Breaking Change 检测)
    • [5.7 生产避坑经验](#5.7 生产避坑经验)
  • 全文总结
  • 本期专栏更新说明
  • 参考资料

前言

当你的 TypeScript 项目从单体应用走向可复用的 npm 包时,你会面临一个全新的挑战:如何让下游消费者获得与你开发时同等的类型安全体验?

这个问题的答案就是 .d.ts 声明文件。它既是 TypeScript 类型系统的分发载体,也是 npm 生态中类型安全链条的关键一环。一个声明文件写得好的库,使用者在 IDE 中可以获得精准的自动补全、参数提示和编译期错误检查;而一个声明文件质量不佳的库,会让使用者在 any 的海洋中迷失方向。

本文将以 AccessGuard v1.1 的模块化发布为实际场景------我们将把前面十篇文章中构建的 IAM 权限控制前端拆分为 @accessguard/core(核心权限引擎)和 @accessguard/react(React 绑定层)两个独立 npm 包,并完成从声明文件编写、打包到 CI/CD 自动发布的全链路实战。

  • 核心痛点: TypeScript 库发布时,声明文件的生成、打包、发布配置复杂且容错率低,配置失当会直接导致下游类型解析失败
  • 前置知识: 需要掌握 TypeScript 基础类型、泛型、模块系统(ESM/CJS)的基本概念,以及 npm 包的基础发布流程
  • 系列阶段: 高级篇第 1 篇(共 6 篇)------ 进入工程化与生态集成阶段,开始从"写类型"转向"发布类型"
  • 收获能力 : 读完本文,你将掌握 .d.ts 文件的完整生命周期管理,能够独立完成 TypeScript 库的模块化发布,并建立类型 Breaking Change 的判定体系

依赖版本(本文撰写时最新稳定版):

依赖 版本
TypeScript 6.0.3
React 19.2.7
Vite 8.0.16
rollup-plugin-dts 6.1.0
@microsoft/api-extractor 7.49.0
pnpm 10.12.0
Changesets 2.28.0

技术背景与演进逻辑

1.1 JavaScript 生态的类型缺口

npm 是世界上最大的包管理器,拥有超过 200 万个包。然而,npm 生态的基石是 JavaScript------一门动态类型语言。在没有 TypeScript 的年代,开发者只能通过文档、示例代码和函数名来"猜测"一个 API 的参数类型和返回值。

2012 年 TypeScript 诞生后,微软面临一个核心问题:如何让 TypeScript 用户安全地调用海量的 JavaScript 库? 如果要求所有库作者用 TypeScript 重写,这显然不现实。于是 .d.ts 声明文件应运而生------它允许在不修改原始 JavaScript 代码的前提下,为现有 JS 库赋予静态类型信息。

复制代码
JavaScript 库 (runtime)
        +
声明文件 (.d.ts) (compile-time)
        ↓
TypeScript 用户获得完整的类型安全

这一设计体现了 TypeScript 的核心理念:渐进式类型化。你不需要一次性迁移整个项目,也不需要所有依赖都使用 TypeScript 编写------只要有一份声明文件,类型系统就能正常工作。

1.2 .d.ts 文件的设计哲学

.d.ts 文件本质上是一份编译期契约 。它只包含类型信息,不包含任何可执行的 JavaScript 代码。可以将其类比为 C/C++ 中的头文件(.h),或者 Rust 中的 trait 定义------它们都定义了"接口",而不提供"实现"。

.d.ts 与 C 头文件有一个关键区别:TypeScript 的声明文件是通过编译器自动生成的 (虽然也可以手写),而非手动维护。当你运行 tsc --declaration 时,编译器会从 .ts 源码中提取所有公共 API 的类型签名 ,生成对应的 .d.ts 文件。

这一"自动生成"的特性带来了两个重要好处:

  1. 声明与实现永远同步 : 只要编译通过,.d.ts 就精确反映了源码的类型结构,不存在声明过期的风险
  2. 降低维护负担 : 库作者不需要手动编写和维护声明文件,专注编写 .ts 源码即可

然而,自动生成也有局限------当库结构复杂、多入口、需要选择性暴露 API 时,原始的 tsc 输出往往需要经过打包处理才能作为最终发布产物。

1.3 AccessGuard 的发布需求

在 AccessGuard 的前十个版本迭代中,我们一直以单体应用的方式开发。权限引擎、React 组件、策略编辑器全部耦合在一个项目中。当功能逐渐成熟、需要在多个项目中复用时,模块化发布成为必然选择。

AccessGuard v1.1 的发布目标:

复制代码
@accessguard/core      核心权限引擎(框架无关)
    ├── RBAC 引擎      角色-权限-用户 检查
    ├── ABAC 引擎      属性级访问控制
    └── 融合引擎       RBAC + ABAC 组合决策

@accessguard/react     React 绑定层
    ├── <Can>          权限守卫组件
    ├── usePermission  Hook
    └── 策略编辑器组件

核心包(core)必须是框架无关 的------它可能在 React、Vue、Node.js 服务端甚至 CLI 工具中使用。React 包(react)则依赖 core 的类型,并提供 React 特定的组件和 Hook。

这一架构对类型发布提出了四个硬性要求:

  1. @accessguard/react 必须能正确引用 @accessguard/core 的类型
  2. 两个包的声明文件需要独立打包,消费者只需安装自己需要的包
  3. 发布类型文件的同时保留源码映射(declaration map),让消费者可以 F12 跳转到原始源码
  4. 类型 Breaking Change 需要被检测和标注,遵循 semver 语义化版本规范

接下来,我们将深入 .d.ts 的核心原理,然后逐步完成 AccessGuard 双包发布的全流程。


核心原理深度解析

2.1 .d.ts 文件的内部结构

让我们从最简单的例子开始。假设有这样一个 TypeScript 源文件:

typescript 复制代码
// src/permission.ts
export type Permission = "read" | "write" | "delete" | "admin";

export interface Role {
  name: string;
  permissions: readonly Permission[];
}

export function hasPermission(
  role: Role,
  permission: Permission
): boolean {
  return role.permissions.includes(permission);
}

当执行 tsc --declaration --emitDeclarationOnly 时,编译器生成的 .d.ts 文件如下:

typescript 复制代码
// dist/permission.d.ts
export type Permission = "read" | "write" | "delete" | "admin";
export interface Role {
    name: string;
    permissions: readonly Permission[];
}
export declare function hasPermission(role: Role, permission: Permission): boolean;

对比源码和声明文件,可以观察到三个关键变化:

  1. 函数实现被移除 : hasPermission 的函数体 { return ... } 被完全丢弃,只保留类型签名,并加上 declare 关键字
  2. 类型定义完整保留 : Permission 类型别名和 Role 接口的结构与源码完全一致
  3. readonly 修饰符保留 : 类型层面的修饰符(如 readonly)在声明文件中忠实呈现,确保消费者无法意外修改

编译器遵循的核心规则是:任何可被外部模块导入的声明,都会被提取到 .d.ts 文件中;仅在模块内部使用的私有实现细节,则被丢弃

2.2 声明空间与值空间的分离

TypeScript 中存在两个平行的命名空间:类型声明空间(Type Declaration Space)变量声明空间(Variable Declaration Space) 。理解这一点对于掌握 .d.ts 的工作机制至关重要。

类型声明空间存放:

  • type 别名
  • interface 接口
  • enum 枚举(同时属于两个空间)
  • 类(类的实例类型属于类型空间)

变量声明空间存放:

  • const / let / var 变量
  • function 函数实现
  • class 构造函数(同时属于两个空间)

.d.ts 文件中,所有属于变量声明空间的内容前都必须加 declare 关键字,表示"这个变量在运行时存在,但类型系统需要知道它的类型":

typescript 复制代码
// 类型声明空间 --- 不需要 declare
export type Permission = "read" | "write";
export interface Role { name: string; }

// 变量声明空间 --- 需要 declare
export declare const DEFAULT_ROLE: Role;
export declare function hasPermission(role: Role, perm: Permission): boolean;
export declare class AccessGuard {
  constructor(config: AccessGuardConfig);
  check(role: Role, perm: Permission): boolean;
}

这个分离机制解释了一个常见困惑:为什么 .d.ts 中有的声明有 declare,有的没有? 答案就是:类型声明(type/interface)天然只存在于编译期,不需要 declare;而值声明(const/function/class)在运行时实际存在,类型系统需要知道它们的存在但不需要知道实现,所以用 declare 标记。

2.3 编译器如何消费声明文件

当 TypeScript 编译器遇到 import { hasPermission } from "@accessguard/core" 时,它会执行以下解析流程:

复制代码
import "@accessguard/core"
    ↓
1. 从 node_modules/@accessguard/core/package.json 读取 "exports" 字段
    ↓
2. 匹配当前模块格式(ESM → import 分支, CJS → require 分支)
    ↓
3. 从匹配的分支中读取 "types" 条件,获取 .d.ts 文件路径
    ↓
4. 加载 .d.ts 文件,从中提取 hasPermission 的类型签名
    ↓
5. 将类型信息注入当前文件的类型检查上下文

如果第 3 步找不到 types 字段,编译器会尝试以下回退策略:

复制代码
没有 "types" 字段?
    ↓
尝试 "typings" 字段(历史兼容)
    ↓
尝试在包根目录查找 index.d.ts
    ↓
查看 "main" 字段指向的 .js 文件同级是否有 .d.ts
    ↓
全部失败 → 模块解析为 any(隐式 any 错误)

这就是为什么配置正确的 types 字段是发布 TypeScript 库的绝对前提 。一个缺失或错误配置的 types 字段会让整个类型链条断裂。

2.4 Declaration Map 与源码溯源

declarationMap: truetsconfig.json 中一个常被忽视但极其重要的选项。它生成的 .d.ts.map 文件将声明文件中的每个类型引用映射回原始 .ts 源文件。

没有 declaration map 时,消费者的 IDE "Go to Definition" 会跳转到打包后的 .d.ts 文件------只能看到扁平化的类型定义。有 declaration map 时,IDE 可以跨过 .d.ts 直接定位到源码:

复制代码
消费者代码: hasPermission(adminRole, "write")
                    │
                    │ F12 (Go to Definition)
                    ↓
           .d.ts.map 映射
                    │
                    ↓
    src/permission.ts(原始源码 + 完整注释)

对于 AccessGuard 这样的库,declaration map 的价值尤其明显------下游开发者需要理解复杂的泛型约束和类型推导逻辑时,直接查看源码远比阅读扁平化的 .d.ts 高效。


核心模块详解

3.1 declare module:为无类型模块赋予类型

declare module 是 TypeScript 类型系统中最强大的"补丁"机制。它允许你在声明文件中为任何模块定义类型,无论这个模块是否由你编写。

场景一:为纯 JavaScript 依赖添加类型

假设 AccessGuard 依赖一个不提供类型的 JS 工具库 object-hash

typescript 复制代码
// types/object-hash.d.ts
declare module "object-hash" {
  interface HashOptions {
    algorithm?: "sha1" | "sha256" | "md5";
    encoding?: "hex" | "base64";
    excludeKeys?: (key: string) => boolean;
  }

  function hash(value: unknown, options?: HashOptions): string;

  export default hash;
}

声明之后,项目中 import hash from "object-hash" 就能获得完整的类型提示和参数检查。

场景二:扩展已有模块的类型

AccessGuard React 包需要扩展 @accessguard/core 的类型来添加 React 上下文信息:

typescript 复制代码
// 在 @accessguard/react 中
declare module "@accessguard/core" {
  interface AccessGuardConfig {
    /** React 组件渲染模式 */
    renderMode?: "concurrent" | "sync";
    /** React Suspense 边界配置 */
    suspenseBoundary?: boolean;
  }
}

这种方式被称为 Module Augmentation(模块扩充)------你不需要修改原始包的类型定义,就能在消费侧安全地扩展类型。

场景三:为样式/资源文件声明类型

在 Vite + React 项目中,导入 CSS、图片等资源时会遇到"Cannot find module"错误。通配符声明可以一劳永逸地解决:

typescript 复制代码
// types/assets.d.ts
declare module "*.module.css" {
  const classes: { readonly [key: string]: string };
  export default classes;
}

declare module "*.svg" {
  const content: string;
  export default content;
}

declare module "*.png" {
  const content: string;
  export default content;
}

3.2 declare namespace:组织全局类型空间

declare namespace 用于在全局作用域中组织类型。在现代 TypeScript 中,ES Module 是首选组织方式,但以下场景仍然需要 namespace:

场景:为全局注入的类型创建命名空间

AccessGuard 在浏览器环境中可以通过 CDN 加载并挂载到 window.AccessGuard 全局对象上。此时需要声明全局 namespace:

typescript 复制代码
// types/accessguard-global.d.ts
declare namespace AccessGuard {
  // 核心配置
  interface Config {
    mode: "rbac" | "abac" | "hybrid";
    strict: boolean;
    cache?: {
      ttl: number;
      maxSize: number;
    };
  }

  // 权限检查结果
  interface AccessResult {
    allowed: boolean;
    reason?: string;
    matchedPolicy?: string;
  }

  // 构造函数签名
  class Engine {
    constructor(config: Config);
    check(userId: string, resource: string, action: string): AccessResult;
    addPolicy(policy: Policy): void;
    removePolicy(policyId: string): void;
  }

  // 类型别名
  type PolicyId = string & { readonly __brand: unique symbol };
  type PermissionAction = "read" | "write" | "delete" | "admin";

  interface Policy {
    id: PolicyId;
    name: string;
    conditions: Condition[];
    effect: "allow" | "deny";
    priority: number;
  }

  interface Condition {
    attribute: string;
    operator: "eq" | "neq" | "contains" | "gt" | "lt" | "in";
    value: string | number | boolean | string[];
  }
}

这样,通过 <script> 标签加载的 AccessGuard 使用者在 TypeScript 中就能直接使用 AccessGuard.EngineAccessGuard.Config 等全局类型,无需任何 import 语句。

关键注意 : declare namespacedeclare module 的区别在于作用域------前者声明的是全局命名空间(任何文件都可以无 import 使用),后者声明的是模块类型(必须通过 import 使用)。

3.3 declare global 与模块扩充

declare global 是另一个重要的全局类型注入机制,它专门用于在模块文件内部扩充全局类型:

typescript 复制代码
// types/accessguard-global-augment.d.ts
export {};  // 确保这是一个模块文件(有 export 语句)

declare global {
  interface Window {
    __ACCESSGUARD_INSTANCE__?: AccessGuard.Engine;
  }

  interface Navigator {
    accessGuardVersion?: string;
  }

  // 扩充全局 Array 类型
  interface Array<T> {
    /**
     * 权限去重:按权限名称去重,保留最高优先级的条目。
     * 仅在 AccessGuard 上下文中可用。
     */
    dedupeByPermissionName?(): T[];
  }
}

这里的 export {} 是必要的------没有它,TypeScript 会认为这是一个脚本文件(非模块),而脚本文件中的 declare global 行为不同。

3.4 声明打包:rollup-plugin-dts vs API Extractor

当项目规模增长、存在多个入口文件时,tsc 直接生成的 .d.ts 文件会保留原始的目录结构------每个源码文件对应一个 .d.ts 文件。但对于 npm 包发布,通常需要将所有公共类型打包为单一或少量的声明文件

目前生态中两种主流方案如下:

方案对比
维度 rollup-plugin-dts @microsoft/api-extractor
类型名称保留 可能重整/改变名称 精确保留原名
重导出处理 可能丢失重导出 正确处理所有重导出
配置复杂度 简单(零配置可用) 较复杂(需要 JSON 配置文件)
多入口支持 原生支持 仅单一入口
API 报告生成 不支持 支持(生成 .api.md)
文档生成 不支持 支持(通过 api-documenter)
生态系统 Rollup/Vite 生态 微软官方工具链
当前状态 维护模式(仅兼容更新) 活跃开发中
AccessGuard 的方案选择

对于 AccessGuard 的发布场景,我们采用分层打包策略

复制代码
@accessguard/core
    tsconfig.build.json  →  tsc 生成 .d.ts 文件
         ↓
    rollup-plugin-dts    →  打包为 dist/index.d.ts
         ↓
    api-extractor        →  生成 API 报告 + 裁剪内部类型

@accessguard/react
    tsconfig.build.json  →  tsc 生成 .d.ts 文件
         ↓
    rollup-plugin-dts    →  打包为 dist/index.d.ts
         ↓
    引用 @accessguard/core 的类型(外部化,不打包进 bundle)

核心原则是:API Extractor 负责质量和报告,rollup-plugin-dts 负责打包 。对于 core 包的公共 API,API Extractor 生成报告供审查;对于实际发布,使用 rollup-plugin-dts 进行打包(因为 core 有多个内部模块需要合并)。

rollup-plugin-dts 配置
typescript 复制代码
// rollup.dts.config.ts
import { dts } from "rollup-plugin-dts";

export default {
  input: "./dist/types/index.d.ts",
  output: [{ file: "./dist/index.d.ts", format: "es" }],
  plugins: [
    dts({
      // 尊重 tsconfig 的 paths 配置
      respectExternal: true,
    }),
  ],
};
API Extractor 配置
json 复制代码
// api-extractor.json
{
  "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
  "mainEntryPointFilePath": "<projectFolder>/dist/types/index.d.ts",
  "bundledPackages": [],
  "compiler": {
    "tsconfigFilePath": "<projectFolder>/tsconfig.json"
  },
  "apiReport": {
    "enabled": true,
    "reportFolder": "<projectFolder>/api-reports/",
    "reportFileName": "accessguard-core.api.md"
  },
  "docModel": {
    "enabled": true
  },
  "dtsRollup": {
    "enabled": true,
    "untrimmedFilePath": "<projectFolder>/dist/index.d.ts"
  },
  "messages": {
    "compilerMessageReporting": {
      "default": {
        "logLevel": "warning"
      }
    },
    "extractorMessageReporting": {
      "default": {
        "logLevel": "warning"
      },
      "ae-forgotten-export": {
        "logLevel": "warning",
        "addToApiReportFile": true
      }
    }
  }
}

API Extractor 的关键功能之一是 Release Tag 裁剪 ------通过 JSDoc 的 @public@beta@alpha@internal 标签控制哪些类型进入最终声明文件:

typescript 复制代码
/**
 * 权限检查引擎的核心入口。
 * @public
 */
export class AccessGuardEngine {
  /**
   * 检查用户是否有指定权限。
   * @public
   */
  check(user: User, resource: Resource, action: Action): AccessDecision {
    // ...
  }

  /**
   * 内部缓存预热,不稳定 API。
   * @internal
   */
  warmupCache(): void {
    // 这个方法不会出现在 .d.ts 文件中
  }
}

3.5 package.json 类型字段全解析

package.json 中有多个与类型发布相关的字段,理解每个字段的确切作用是正确配置的前提。

字段速查表
字段 作用 示例值 优先级
exports["."].import.types ESM 导入时的类型路径 "./dist/index.d.ts" 最高
exports["."].require.types CJS require 时的类型路径 "./dist/index.d.cts" 最高
types 回退类型声明路径 "./dist/index.d.ts" 中(有 exports 则被覆盖)
typings types 的历史别名 "./dist/index.d.ts" 最低
typesVersions 按 TypeScript 版本分发不同声明 {">=5.0": {...}} 特殊场景
exports["."].types exports 内的简写(不推荐) --- 条件不明确(不用)
正确的 exports 配置
json 复制代码
{
  "name": "@accessguard/core",
  "version": "1.1.0",
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./rbac": {
      "import": {
        "types": "./dist/rbac/index.d.ts",
        "default": "./dist/rbac/index.js"
      },
      "require": {
        "types": "./dist/rbac/index.d.cts",
        "default": "./dist/rbac/index.cjs"
      }
    }
  },
  "sideEffects": false,
  "files": [
    "dist",
    "README.md",
    "LICENSE"
  ]
}

注意:types 必须是每个条件分支中的第一个键 。这是因为 Node.js 和 TypeScript 的条件匹配是自上而下 的------如果 default 出现在 types 之前,TypeScript 的模块解析器会先匹配到 default,从而错误地将 .js 文件作为类型文件使用。

typesVersions 的高级用法

当需要根据消费者的 TypeScript 版本提供不同的声明文件时,使用 typesVersions

json 复制代码
{
  "types": "./dist/index.d.ts",
  "typesVersions": {
    ">=5.5": {
      "*": ["./dist/types-5.5/*"]
    },
    ">=5.0": {
      "*": ["./dist/types-5.0/*"]
    }
  }
}

每个键是 semver 范围(如 >=5.5),值是一个映射表------将包路径模式映射到声明文件目录。TypeScript 会按顺序匹配,选择第一个满足条件的版本范围。

对于 AccessGuard,我们当前不需要版本差异化------TypeScript 6.0 是唯一目标版本。但保留这个选项以备将来升级。

3.6 Monorepo 双包发布:@accessguard/core + @accessguard/react

AccessGuard v1.1 采用 pnpm workspace Monorepo 架构。项目结构如下:

复制代码
accessguard/
    pnpm-workspace.yaml
    package.json                 (根工作区)
    tsconfig.base.json           (共享 TS 配置)
    packages/
        core/
            package.json
            tsconfig.json
            src/
                index.ts         (公共 API 入口)
                rbac/            (RBAC 引擎)
                abac/            (ABAC 引擎)
                fusion/          (融合引擎)
                types/           (核心类型定义)
        react/
            package.json
            tsconfig.json
            src/
                index.ts         (公共 API 入口)
                components/      (Can, CanButton 等)
                hooks/           (usePermission 等)
                context/         (AccessGuardProvider)

pnpm workspace 配置:

yaml 复制代码
# pnpm-workspace.yaml
packages:
  - "packages/*"

共享的 TypeScript 基础配置:

jsonc 复制代码
// tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

@accessguard/core 的 tsconfig:

jsonc 复制代码
// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declarationDir": "./dist/types"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

@accessguard/react 的 tsconfig------通过 TypeScript 的 Project References 和 paths 引用 core 的类型:

jsonc 复制代码
// packages/react/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declarationDir": "./dist/types",
    "paths": {
      "@accessguard/core": ["../core/src/index.ts"]
    }
  },
  "references": [
    { "path": "../core" }
  ],
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

关键设计决策 :开发时 react 通过 paths 直接引用 core 的源码(以获取实时类型更新),但发布后消费者通过 node_modules/@accessguard/core.d.ts 解析类型------两者路径不同但类型兼容。


技术优缺点与适用场景

技术优势

  1. 自动化类型生成,告别手工维护tsc --declaration 从源码自动提取类型签名,声明文件永远与实现同步。团队无需维护两套独立的"接口定义"和"实现代码"。

  2. 渐进式采用,对 JS 生态友好declare module 和 DefinitelyTyped(@types/*)机制让 TypeScript 用户可以安全地调用任何 JavaScript 库,无需上游配合。

  3. 编译期 API 契约.d.ts 文件是天然的 API 文档------下游消费者不需要阅读源码就能理解包的类型结构。配合 declarationMap,消费者可以直接跳转到源码深入研究。

  4. 选择性 API 暴露 :通过 API Extractor 的 release tag 机制(@public/@internal),库作者可以在源码中保留内部实现详情,而只暴露经过审核的公共 API。

  5. 双重分发(ESM + CJS):现代工具链(tsup/tshy)支持从同一份源码同时生成 ESM 和 CJS 两种格式的 JS 和 DTS 文件,覆盖所有消费者的运行环境。

现存局限

  1. 声明打包工具碎片化:rollup-plugin-dts 进入维护模式,API Extractor 学习曲线陡峭,tsup 的实验性选项尚未稳定。没有一个"银弹"方案。

  2. 条件类型在声明中的保真度受限 :高度复杂的泛型、递归条件类型和模板字面量类型在 .d.ts 打包过程中可能出现类型膨胀(type bloat)或名称重整。

  3. CJS/ESM 双格式的类型歧义.d.ts.d.cts 的区别在生态中尚未完全普及,部分工具链默认只处理 .d.ts,导致 CJS 消费者可能拿到 ESM 类型文件。

  4. 类型 Breaking Change 检测缺乏统一标准:semver 对"什么是类型 Breaking Change"没有明确定义,团队需要自行建立判定体系。

生产适用场景

  1. 企业级 npm 包库: 像 AccessGuard 这样需要在多个项目中复用的核心库,类型声明质量直接影响所有下游团队的生产效率
  2. 开源 TypeScript 库 : 有外部用户的开源库,.d.ts 是用户评判库质量的第一印象
  3. Monorepo 内部类型共享: 大型 Monorepo 中通过包引用实现跨项目类型复用,避免代码重复

禁忌场景

  1. 内部一次性脚本/工具 : 不需要发布的内部工具,配置 .d.ts 打包和发布流水线是过度工程化
  2. 纯运行时库(无编译期依赖) : 如果库只做运行时操作且 API 极简单,手写一个 .d.ts 比完整配置打包工具更经济

实战落地

5.1 项目初始化与环境配置

首先创建 Monorepo 根目录结构:

bash 复制代码
mkdir accessguard && cd accessguard
pnpm init
bash 复制代码
# 创建 workspace 配置
echo 'packages:
  - "packages/*"' > pnpm-workspace.yaml

创建核心包的 package.json

json 复制代码
{
  "name": "@accessguard/core",
  "version": "1.1.0",
  "description": "AccessGuard 企业级 IAM 权限引擎核心 --- 框架无关的 RBAC + ABAC 融合引擎",
  "type": "module",
  "license": "MIT",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "build": "tsc -p tsconfig.build.json && rollup -c rollup.dts.config.ts --configPlugin typescript",
    "lint": "tsc --noEmit",
    "prepublishOnly": "pnpm build"
  },
  "devDependencies": {
    "rollup": "^4.35.0",
    "rollup-plugin-dts": "^6.1.0",
    "typescript": "^6.0.3"
  }
}

5.2 编写核心类型的声明文件

AccessGuard 核心类型按模块组织:

typescript 复制代码
// packages/core/src/types/permission.ts

/**
 * 权限操作枚举。
 * @public
 */
export type PermissionAction = "read" | "write" | "delete" | "admin";

/**
 * 权限描述符 --- 将资源标识与操作组合。
 * @public
 */
export interface PermissionDescriptor {
  /** 资源标识 */
  resource: string;
  /** 允许的操作 */
  action: PermissionAction;
  /** 可选约束(ABAC 属性条件) */
  constraints?: Record<string, unknown>;
}

/**
 * 权限实体 --- 运行时使用的完整权限对象。
 * @public
 */
export interface Permission extends PermissionDescriptor {
  /** 权限唯一标识(品牌类型,防止与普通字符串混淆) */
  id: PermissionId;
  /** 权限名称(人类可读) */
  name: string;
  /** 权限描述 */
  description?: string;
}

/**
 * 品牌类型 --- 在类型层面区分不同 ID 类型。
 * @public
 */
export type PermissionId = string & { readonly __brand: unique symbol };

/**
 * 创建品牌 ID 的类型安全工厂函数。
 * @public
 */
export declare function createPermissionId(id: string): PermissionId;
typescript 复制代码
// packages/core/src/types/role.ts

import type { Permission, PermissionId } from "./permission";

/**
 * 角色定义 --- RBAC 的核心概念。
 * @public
 */
export interface Role {
  /** 角色唯一标识 */
  id: RoleId;
  /** 角色名称(人类可读) */
  name: string;
  /** 角色拥有的权限列表 */
  permissions: readonly PermissionId[];
  /** 角色优先级(用于冲突解决,数值越大优先级越高) */
  priority: number;
  /** 父角色 ID(实现角色层级) */
  parentRoleId?: RoleId;
}

export type RoleId = string & { readonly __brand: unique symbol };

export declare function createRoleId(id: string): RoleId;
typescript 复制代码
// packages/core/src/engine.ts --- RBAC 引擎入口

import type { Permission, PermissionId, PermissionAction } from "./types/permission";
import type { Role } from "./types/role";

/**
 * RBAC 引擎配置。
 * @public
 */
export interface RBACEngineConfig {
  /** 是否启用严格模式(未知权限默认拒绝) */
  strict: boolean;
  /** 权限缓存 TTL(毫秒) */
  cacheTTL: number;
}

/**
 * 权限检查结果。
 * @public
 */
export type AccessDecision = "allow" | "deny" | "not_applicable";

/**
 * RBAC 权限检查引擎。
 * @public
 */
export declare class RBACEngine {
  /**
   * @param config - 引擎配置
   * @param roleStore - 角色存储(注入依赖)
   */
  constructor(config: RBACEngineConfig, roleStore: RoleStore);

  /**
   * 检查用户是否拥有指定权限。
   * 自动计算角色层级------如果用户属于父角色,则自动拥有所有子角色权限。
   */
  check(userId: string, permission: PermissionAction, resource: string): AccessDecision;

  /**
   * 获取用户的全部有效权限(含角色继承)。
   */
  getUserPermissions(userId: string): readonly PermissionId[];

  /**
   * 获取用户的全部有效角色(含角色层级展开)。
   */
  getUserRoles(userId: string): readonly Role[];
}

/**
 * 角色存储接口------可替换实现(内存/数据库/LDAP)。
 * @public
 */
export interface RoleStore {
  getUserRoleIds(userId: string): readonly string[];
  getRoleById(roleId: string): Role | undefined;
  getRoleByName(name: string): Role | undefined;
  getAllRoles(): readonly Role[];
}
typescript 复制代码
// packages/core/src/index.ts --- 公共 API 汇总

export { RBACEngine } from "./engine";
export type { RBACEngineConfig, AccessDecision, RoleStore } from "./engine";

export type {
  Permission,
  PermissionDescriptor,
  PermissionAction,
  PermissionId,
} from "./types/permission";
export { createPermissionId } from "./types/permission";

export type { Role, RoleId } from "./types/role";
export { createRoleId } from "./types/role";

5.3 配置声明打包

typescript 复制代码
// rollup.dts.config.ts
import { dts } from "rollup-plugin-dts";
import type { RollupOptions } from "rollup";

const config: RollupOptions = {
  input: "./dist/types/index.d.ts",
  output: [{ file: "./dist/index.d.ts", format: "es" }],
  plugins: [
    dts({
      respectExternal: true,
    }),
  ],
  external: [
    // 不打包的标准类型库
    /^typescript$/,
  ],
};

export default config;

构建脚本:

json 复制代码
{
  "scripts": {
    "build:js": "tsc -p tsconfig.build.json",
    "build:types": "rollup --config rollup.dts.config.ts --configPlugin typescript",
    "build": "pnpm build:js && pnpm build:types",
    "build:cjs": "tsc -p tsconfig.build.cjs.json",
    "build:all": "pnpm build && pnpm build:cjs"
  }
}

5.4 配置 package.json 发布字段

完整的 @accessguard/reactpackage.json

json 复制代码
{
  "name": "@accessguard/react",
  "version": "1.1.0",
  "description": "AccessGuard React 绑定 --- 类型安全的权限守卫组件与 Hooks",
  "type": "module",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./components": {
      "import": {
        "types": "./dist/components/index.d.ts",
        "default": "./dist/components/index.js"
      },
      "require": {
        "types": "./dist/components/index.d.cts",
        "default": "./dist/components/index.cjs"
      }
    },
    "./hooks": {
      "import": {
        "types": "./dist/hooks/index.d.ts",
        "default": "./dist/hooks/index.js"
      },
      "require": {
        "types": "./dist/hooks/index.d.cts",
        "default": "./dist/hooks/index.cjs"
      }
    }
  },
  "peerDependencies": {
    "@accessguard/core": "^1.1.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "dependencies": {
    "@accessguard/core": "^1.1.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "typescript": "^6.0.3"
  },
  "sideEffects": false,
  "files": ["dist"],
  "keywords": [
    "typescript",
    "access-control",
    "rbac",
    "abac",
    "iam",
    "react",
    "permissions"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/accessguard/accessguard"
  }
}

5.5 CI/CD 自动发布流水线

使用 GitHub Actions 实现类型安全的自动发布流水线:

yaml 复制代码
# .github/workflows/publish.yml
name: Publish Packages

on:
  push:
    branches: [main]
    paths:
      - "packages/**/src/**"
      - "packages/**/package.json"

concurrency:
  group: publish-${{ github.ref }}
  cancel-in-progress: true

jobs:
  typecheck:
    name: Type Check All Packages
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ["core", "react"]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - name: Type Check ${{ matrix.package }}
        run: pnpm --filter @accessguard/${{ matrix.package }} run lint

  build:
    name: Build All Packages
    needs: typecheck
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - name: Build All
        run: pnpm -r run build
      - name: Check Published Types
        run: |
          npx @arethetypeswrong/cli --pack packages/core
          npx @arethetypeswrong/cli --pack packages/react
      - name: Upload Build Artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: packages/*/dist/

  release:
    name: Release or Publish
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
          registry-url: "https://registry.npmjs.org"
      - run: pnpm install --frozen-lockfile
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: packages/
      - name: Create Release PR or Publish
        uses: changesets/action@v1
        with:
          version: pnpm changeset version
          publish: pnpm changeset publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

流水线的三个阶段各有侧重:

复制代码
代码推送 (main 分支)
    ↓
[TypeCheck] 并行检查两个包的类型
    │
    ├── core: tsc --noEmit
    └── react: tsc --noEmit
    ↓
[Build] 全量构建 + 类型发布检查
    │
    ├── tsc + rollup-plugin-dts
    ├── @arethetypeswrong/cli 验证
    └── 上传构建产物
    ↓
[Release] Changesets 版本管理
    │
    ├── changeset version (如果有 changeset 文件)
    ├── changeset publish (自动发布到 npm)
    └── 生成 Release PR

5.6 类型 Breaking Change 检测

类型层面的 Breaking Change 通常比运行时 API 变更更微妙。以下是需要判定为 Major 版本升级(Breaking Change)的场景:

Breaking Change 判定表
变更类型 示例 影响
删除导出的类型 export type X → (删除) 下游编译失败
收缩联合类型 `"a" "b""a"`
新增必需属性 { a: string }{ a: string; b: number } 下游对象字面量缺少新属性
泛型约束收窄 <T><T extends string> 下游传递了不满足新约束的类型
函数参数新增 f(x: A)f(x: A, y: B) 下游调用缺少新参数
返回类型变化 f(): stringf(): number 下游的类型推导断裂
readonly 移除 readonly T[]T[] 下游可能意外修改了数据
自动化检测方案
复制代码
每次 PR (pull request)
    ↓
CI 触发类型差异分析
    ↓
对比 PR 前后 api-extractor 生成的 .api.md 报告
    ↓
检测到 Breaking Change  →  PR 被标记为 "Major"
未检测到                →  PR 被标记为 "Minor/Patch"
    ↓
Author 确认后合并

实现这个流程的关键工具是 API Extractor 的 .api.md 报告------它是公共 API 的可读快照,每次构建都会更新。通过 git diff 对比 PR 前后的 .api.md,可以自动检测出类型的增删改。

bash 复制代码
# 在 CI 中执行
git diff origin/main -- packages/core/api-reports/accessguard-core.api.md

# 如果 diff 中包含 "Removed"、"Modified" 等标记
# → 判定为 Breaking Change
# → CI 自动在 PR 上添加 "breaking-change" 标签

5.7 生产避坑经验

坑 1:types 不是 exports 条件分支中的第一个键

问题表现 : 消费者在 IDE 中看到 any 类型,且 tsc --noEmit 报"无法找到模块的声明文件"。

根本原因 : exports 的条件匹配是自上而下的。如果在 types 之前放置了 default,TypeScript 会匹配到 .js 文件并停止,永远不会看到 types 条件。

正确配置:

json 复制代码
{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",   // 第一个
        "default": "./dist/index.js"    // 第二个
      }
    }
  }
}
坑 2:声明的模块路径与实际安装路径不匹配

问题表现 : @accessguard/react 在开发环境类型正常,但消费者安装后报"Cannot find module '@accessguard/core' or its corresponding type declarations"。

根本原因 : 开发时使用了 tsconfig paths 指向 ../core/src,但发布到 npm 后,消费者的 node_modulescorereact 是平级安装的,路径结构不同。

解决方案 : 在 reactpackage.json 中将 core 放在 dependencies(而非 devDependencies)中,确保消费者每次安装 react 时也安装 core。发布前使用 @arethetypeswrong/cli 验证实际安装后的类型解析。

坑 3:.d.ts 包含了对 devDependencies 的类型引用

问题表现 : 消费者安装包后类型报错,提示找不到某个类型------这个类型来自发布者的 devDependencies,消费者没有安装。

根本原因 : 公共 API 的类型签名引用了仅安装在 devDependencies 中的包的类型。例如,你的导出函数接收一个 React.FC 类型参数,但 @types/react 放在了 devDependencies 中。

解决方案 : 任何被公共 API 引用的类型,其对应的 @types/* 包必须放在 dependenciespeerDependencies 中。具体来说:

  • 如果类型是硬依赖(如你的函数接收 React.ReactNode),放在 peerDependencies + devDependencies
  • 如果类型只出现在内部实现中,devDependencies 即可
坑 4:忽略 sideEffects 字段

问题表现: 消费者打包应用后,AccessGuard 的代码体积远超预期。

根本原因 : 没有设置 "sideEffects": false,导致 Webpack/Rollup 等打包器不敢对库进行 tree-shaking,将所有导出全部保留。

解决方案 : 在 package.json 中明确声明 "sideEffects": false。如果某些文件确实有副作用(如全局 CSS),单独列出。

坑 5:生成 .d.ts.map 但未发布

问题表现 : 消费者 IDE 的"Go to Definition"跳到 .d.ts 文件而非原始源码。

根本原因 : 构建时生成了 .d.ts.map 文件,但 package.jsonfiles 字段或 .npmignore 没有包含 .d.ts.map

解决方案 : 确保 files 字段包含 dist 目录(而非具体文件名),这样 dist/ 下的所有文件(包括 .d.ts.map)都会被发布。


全文总结

本文以 AccessGuard v1.1 的模块化发布为贯穿案例,深度覆盖了 TypeScript 声明文件的全生命周期:

  1. 核心原理 : .d.ts 文件是编译期类型契约------tsc --declaration 从源码自动提取公共 API 的类型签名,去掉所有实现细节,仅保留类型骨架。声明空间与值空间的分离机制(declare 关键字的使用规则)是理解声明文件的基础。

  2. 声明编写 : declare module 为无类型 JS 库赋予类型安全;declare namespace 组织全局类型空间;declare global 在模块内部扩充全局类型。三者各有适用场景,组合使用可覆盖几乎所有类型补丁需求。

  3. 声明打包: rollup-plugin-dts(简单易用,现已进入维护模式)与 API Extractor(微软维护,精确保留类型名称,支持 release tag 裁剪和 API 报告生成)是两大主流方案。AccessGuard 采用分层策略:tsc 生成原始声明 + rollup-plugin-dts 打包 + API Extractor 生成质量报告。

  4. 发布配置 : package.jsonexports 字段是类型分发的核心------types 必须是每个条件分支的第一个键;files 字段精确控制发布内容;sideEffects 启用 tree-shaking。@accessguard/core@accessguard/react 的双包架构依靠 peerDependencies 管理类型依赖。

  5. Breaking Change 检测 : 类型层面的破坏性变更比运行时 API 变更更隐蔽------收缩联合类型、新增必需属性、泛型约束收窄、返回类型变化都可能直接导致下游编译失败。API Extractor 的 .api.md 报告配合 CI 自动 diff,可以实现 Breaking Change 的机械化检测。

  6. 工程化流水线: CI 分三阶段------TypeCheck(并行类型检查)→ Build(构建 + attw 验证)→ Release(Changesets 自动发版)。每个阶段独立运行,前置阶段失败阻止后续阶段执行,确保只有通过全部检查的类型产物才能进入 npm 注册表。


本期专栏更新说明

本文为《TypeScript 从入门到精通》专栏第一季(AccessGuard 贯穿案例)的第十一篇------进入高级篇阶段。高级篇共规划 6 篇文章,聚焦工程化与生态集成:声明文件与 npm 发布、状态管理类型设计(Zustand 类型化)、API 层类型安全(OpenAPI → TypeScript)、TSConfig 深度配置、泛型表单系统、国际化类型安全。

专栏长期更新类型系统内核、高级类型体操、Compiler API、React 类型集成与生产级 Monorepo 架构,一次订阅可永久持续接收更新。第一季(AccessGuard)共 24 篇,完结后将开启第二季,以全新贯穿案例重新从入门螺旋------两季独立成体系,读者可从任意一季的入门篇开始。


参考资料