【高级】AccessGuard v1.1:声明文件与类型发布 --- TypeScript .d.ts 编写与 npm 包深度实战
标签: TypeScript, 前端, IAM, 声明文件, .d.ts, npm, 权限控制, Monorepo, 类型发布
摘要 : 当 AccessGuard 从单体项目走向多包发布时,类型声明文件(.d.ts)的质量直接决定了下游消费者的开发体验。本文以 AccessGuard v1.1 的模块化发布为场景,深度拆解 TypeScript 声明文件的生成、打包、发布全链路:从 declare module 与 declare 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 文件。
这一"自动生成"的特性带来了两个重要好处:
- 声明与实现永远同步 : 只要编译通过,
.d.ts就精确反映了源码的类型结构,不存在声明过期的风险 - 降低维护负担 : 库作者不需要手动编写和维护声明文件,专注编写
.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。
这一架构对类型发布提出了四个硬性要求:
@accessguard/react必须能正确引用@accessguard/core的类型- 两个包的声明文件需要独立打包,消费者只需安装自己需要的包
- 发布类型文件的同时保留源码映射(declaration map),让消费者可以 F12 跳转到原始源码
- 类型 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;
对比源码和声明文件,可以观察到三个关键变化:
- 函数实现被移除 :
hasPermission的函数体{ return ... }被完全丢弃,只保留类型签名,并加上declare关键字 - 类型定义完整保留 :
Permission类型别名和Role接口的结构与源码完全一致 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: true 是 tsconfig.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.Engine、AccessGuard.Config 等全局类型,无需任何 import 语句。
关键注意 : declare namespace 与 declare 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 解析类型------两者路径不同但类型兼容。
技术优缺点与适用场景
技术优势
-
自动化类型生成,告别手工维护 :
tsc --declaration从源码自动提取类型签名,声明文件永远与实现同步。团队无需维护两套独立的"接口定义"和"实现代码"。 -
渐进式采用,对 JS 生态友好 :
declare module和 DefinitelyTyped(@types/*)机制让 TypeScript 用户可以安全地调用任何 JavaScript 库,无需上游配合。 -
编译期 API 契约 :
.d.ts文件是天然的 API 文档------下游消费者不需要阅读源码就能理解包的类型结构。配合declarationMap,消费者可以直接跳转到源码深入研究。 -
选择性 API 暴露 :通过 API Extractor 的 release tag 机制(
@public/@internal),库作者可以在源码中保留内部实现详情,而只暴露经过审核的公共 API。 -
双重分发(ESM + CJS):现代工具链(tsup/tshy)支持从同一份源码同时生成 ESM 和 CJS 两种格式的 JS 和 DTS 文件,覆盖所有消费者的运行环境。
现存局限
-
声明打包工具碎片化:rollup-plugin-dts 进入维护模式,API Extractor 学习曲线陡峭,tsup 的实验性选项尚未稳定。没有一个"银弹"方案。
-
条件类型在声明中的保真度受限 :高度复杂的泛型、递归条件类型和模板字面量类型在
.d.ts打包过程中可能出现类型膨胀(type bloat)或名称重整。 -
CJS/ESM 双格式的类型歧义 :
.d.ts与.d.cts的区别在生态中尚未完全普及,部分工具链默认只处理.d.ts,导致 CJS 消费者可能拿到 ESM 类型文件。 -
类型 Breaking Change 检测缺乏统一标准:semver 对"什么是类型 Breaking Change"没有明确定义,团队需要自行建立判定体系。
生产适用场景
- 企业级 npm 包库: 像 AccessGuard 这样需要在多个项目中复用的核心库,类型声明质量直接影响所有下游团队的生产效率
- 开源 TypeScript 库 : 有外部用户的开源库,
.d.ts是用户评判库质量的第一印象 - Monorepo 内部类型共享: 大型 Monorepo 中通过包引用实现跨项目类型复用,避免代码重复
禁忌场景
- 内部一次性脚本/工具 : 不需要发布的内部工具,配置
.d.ts打包和发布流水线是过度工程化 - 纯运行时库(无编译期依赖) : 如果库只做运行时操作且 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/react 的 package.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(): string → f(): 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_modules 中 core 和 react 是平级安装的,路径结构不同。
解决方案 : 在 react 的 package.json 中将 core 放在 dependencies(而非 devDependencies)中,确保消费者每次安装 react 时也安装 core。发布前使用 @arethetypeswrong/cli 验证实际安装后的类型解析。
坑 3:.d.ts 包含了对 devDependencies 的类型引用
问题表现 : 消费者安装包后类型报错,提示找不到某个类型------这个类型来自发布者的 devDependencies,消费者没有安装。
根本原因 : 公共 API 的类型签名引用了仅安装在 devDependencies 中的包的类型。例如,你的导出函数接收一个 React.FC 类型参数,但 @types/react 放在了 devDependencies 中。
解决方案 : 任何被公共 API 引用的类型,其对应的 @types/* 包必须放在 dependencies 或 peerDependencies 中。具体来说:
- 如果类型是硬依赖(如你的函数接收
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.json 的 files 字段或 .npmignore 没有包含 .d.ts.map。
解决方案 : 确保 files 字段包含 dist 目录(而非具体文件名),这样 dist/ 下的所有文件(包括 .d.ts.map)都会被发布。
全文总结
本文以 AccessGuard v1.1 的模块化发布为贯穿案例,深度覆盖了 TypeScript 声明文件的全生命周期:
-
核心原理 :
.d.ts文件是编译期类型契约------tsc --declaration从源码自动提取公共 API 的类型签名,去掉所有实现细节,仅保留类型骨架。声明空间与值空间的分离机制(declare关键字的使用规则)是理解声明文件的基础。 -
声明编写 :
declare module为无类型 JS 库赋予类型安全;declare namespace组织全局类型空间;declare global在模块内部扩充全局类型。三者各有适用场景,组合使用可覆盖几乎所有类型补丁需求。 -
声明打包: rollup-plugin-dts(简单易用,现已进入维护模式)与 API Extractor(微软维护,精确保留类型名称,支持 release tag 裁剪和 API 报告生成)是两大主流方案。AccessGuard 采用分层策略:tsc 生成原始声明 + rollup-plugin-dts 打包 + API Extractor 生成质量报告。
-
发布配置 :
package.json的exports字段是类型分发的核心------types必须是每个条件分支的第一个键;files字段精确控制发布内容;sideEffects启用 tree-shaking。@accessguard/core和@accessguard/react的双包架构依靠 peerDependencies 管理类型依赖。 -
Breaking Change 检测 : 类型层面的破坏性变更比运行时 API 变更更隐蔽------收缩联合类型、新增必需属性、泛型约束收窄、返回类型变化都可能直接导致下游编译失败。API Extractor 的
.api.md报告配合 CI 自动 diff,可以实现 Breaking Change 的机械化检测。 -
工程化流水线: CI 分三阶段------TypeCheck(并行类型检查)→ Build(构建 + attw 验证)→ Release(Changesets 自动发版)。每个阶段独立运行,前置阶段失败阻止后续阶段执行,确保只有通过全部检查的类型产物才能进入 npm 注册表。
本期专栏更新说明
本文为《TypeScript 从入门到精通》专栏第一季(AccessGuard 贯穿案例)的第十一篇------进入高级篇阶段。高级篇共规划 6 篇文章,聚焦工程化与生态集成:声明文件与 npm 发布、状态管理类型设计(Zustand 类型化)、API 层类型安全(OpenAPI → TypeScript)、TSConfig 深度配置、泛型表单系统、国际化类型安全。
专栏长期更新类型系统内核、高级类型体操、Compiler API、React 类型集成与生产级 Monorepo 架构,一次订阅可永久持续接收更新。第一季(AccessGuard)共 24 篇,完结后将开启第二季,以全新贯穿案例重新从入门螺旋------两季独立成体系,读者可从任意一季的入门篇开始。
参考资料
- TypeScript 官方手册 --- Declaration Files Publishing
- API Extractor 官方文档
- rollup-plugin-dts GitHub
- @arethetypeswrong/cli (attw)
- publint --- npm package.json linter
- Shipping Dual ESM/CJS in 2026: The Library Author Checklist
- Why JSR Should Be Your Default Registry for New TypeScript Libraries in 2026
- Publishing npm Packages: Complete Guide 2026
- dtsroll --- Declaration file bundler for libraries built with tsc
- tsup --- Bundle TypeScript libraries with no config
- Changesets --- A tool to manage versioning and changelogs