本节目标:读完这一节,你能判断一个库到底有没有类型、该走哪条路补类型;能从
index.js的导出形态反推出declare module的正确骨架;能处理重载、可选参数、class与namespace的合并、CommonJS 的export =;能为.css/.svg/?raw这类资源写通配声明;并且知道用什么手段保证声明与实现不会悄悄脱节。
12.2 为无类型库编写声明
上一节讲了 .d.ts 的机制与来源。这一节换个角度:当三条来源全都落空时,你得自己写。
这是每个 TypeScript 工程师迟早会遇到的场景,而且往往是入职第一天——公司十年前的内部 SDK 就是这么个只有 .js 的黑盒。
先确认真的没有类型
不要一上来就动手写。先花两分钟确认现状:
npm view legacy-logger types typings
npm view @types/legacy-logger version
两条命令分别回答两个问题:
| 检查项 | 结果含义 | 下一步 |
|---|---|---|
types 字段有值 | 包自带类型 | 不用写,检查解析是否正常 |
@types/xxx 有版本 | 社区已提供 | npm i -D @types/xxx |
| 都没有 | 需要自己写 | 继续往下读 |
还有一种情况最容易被误判成「没类型」:包其实自带了 .d.ts,只是解析配置不对导致编辑器不认。这时先看 11.2 ESM/CJS 互操作与 moduleResolution
,而不是急着重写一份。
什么时候必须自己写
| 场景 | 例子 | 声明放在哪 |
|---|---|---|
| 无类型的老库 | 内部的 legacy-logger | 仓库内 typings/ |
| 私有 SDK | 公司内网 npm 上的 @corp/trace | 仓库内 typings/ |
| 环境注入的全局对象 | window.__APP_CONFIG__ | typings/globals.d.ts |
| 非 JS 资源 | .css、.svg、?raw 导入 | typings/assets.d.ts |
| 运行时生成的模块 | 插件系统动态加载 | 通配声明 |
共同点是:这些模块在编译期无法被 TypeScript 推导,但运行时确实存在。
从源码反推签名
写声明的第一步不是打开编辑器,而是打开那个库的 index.js。看三件事:导出形态、参数默认值、返回值。
// node_modules/legacy-logger/index.js
function createLogger(options) {
const opts = Object.assign({ level: "info", silent: false }, options);
function log(level, message, meta) {
if (opts.silent) return;
console[level](message, meta ?? "");
}
return {
info: (m, meta) => log("info", m, meta),
warn: (m, meta) => log("warn", m, meta),
error: (m, meta) => log("error", m, meta),
setLevel: (level) => { opts.level = level; },
};
}
module.exports = { createLogger };
读出来两个事实:
module.exports是对象,不是函数,也不是export default;options可以省略(Object.assign有默认值兜底),所以是可选参数。
据此写出的声明:
// typings/legacy-logger.d.ts
declare module "legacy-logger" {
export type LogLevel = "info" | "warn" | "error";
export interface LoggerOptions {
level?: LogLevel;
silent?: boolean;
}
export interface Logger {
info(message: string, meta?: unknown): void;
warn(message: string, meta?: unknown): void;
error(message: string, meta?: unknown): void;
setLevel(level: LogLevel): void;
}
export function createLogger(options?: LoggerOptions): Logger;
}
注意 LogLevel 用的是字面量联合而不是 string。这是自己写声明时最能提升价值的地方:库没有类型,但你知道取值只有三个,就把这个知识固化进类型里。
import { createLogger } from "legacy-logger";
const logger = createLogger({ level: "debug" });
// ^^^^^^^
// Argument of type '"debug"' is not assignable to parameter of type 'LogLevel'.
三种粒度的声明策略
同样是补类型,写法有三种,选择取决于「这个库会怎么被消费」:
| 策略 | 写法 | 适用 | 缺点 |
|---|---|---|---|
declare module 内联 | 在任意被 include 的 .d.ts 里写 declare module "x" | 内部库、一次性补丁 | 无法发布复用 |
| 就近声明目录 | typings/<pkg>/index.d.ts + typeRoots | 需要模拟真实包结构 | 配置多一步 |
发布 @types 包 | 独立仓库/目录发布 | 开源库、多项目复用 | 流程重,需评审 |
对绝大多数项目,第一种就够了:新建 typings/ 目录,把声明按包名拆文件,并确保 tsconfig.json 的 include 覆盖它(写法与 12.1 相同)。
一个容易忽略的点:typings/ 千万不要放在 src/ 里,否则 tsc --declaration 会把它们当成源码再复制一遍到输出目录。
函数重载与可选参数
JS 库经常用「参数个数/类型不同、行为不同」的写法。声明的表达方式就是重载:
// JS 原貌:format("abc") / format(1234, 2) / format(new Date())
declare module "legacy-format" {
export function format(input: string): string;
export function format(input: number, digits: number): string;
export function format(input: Date): string;
}
重载声明必须从具体到宽泛排列,否则宽泛的那条会把后面的全部遮住。这和函数实现的写法一致,见 4.2 重载、this 类型与箭头函数 。
可选参数用 ?,但要注意「可选」和「可为 undefined」的区别:
declare module "legacy-logger" {
// 调用时可以不传
export function info(message: string, meta?: unknown): void;
// 必须传,但值可以是 undefined
export function error(message: string, meta: unknown): void;
}
如果库里用了剩余参数,声明写成数组形式:
declare module "legacy-logger" {
export function log(level: string, ...args: unknown[]): void;
}
类与命名空间
当库导出的是一个构造函数(ES5 风格),声明用 declare class:
// JS 原貌:new EventBus({ maxListeners: 10 })
declare module "legacy-event-bus" {
export interface EventBusOptions {
maxListeners?: number;
}
export class EventBus {
constructor(options?: EventBusOptions);
on(event: string, handler: (...args: unknown[]) => void): this;
off(event: string, handler: (...args: unknown[]) => void): this;
emit(event: string, ...args: unknown[]): boolean;
}
}
有一个细节值得强调:on / off 返回 this 而不是 EventBus,这样链式调用 bus.on("a", f).on("b", g) 才有正确类型;emit 返回的 boolean 表示「是否有监听器接收了事件」,这是从源码读出来的,不是猜的。
如果库把类型挂在类上也一起导出(EventBus.Options),就用类与命名空间合并来表达:
declare module "legacy-event-bus" {
export class EventBus {
constructor(options?: EventBus.Options);
on(event: string, handler: (...args: unknown[]) => void): this;
emit(event: string, ...args: unknown[]): boolean;
}
export namespace EventBus {
interface Options {
maxListeners?: number;
}
const version: string;
}
}
namespace 与同名 class 合并后,EventBus.Options 既是类型又能被引用,EventBus.version 则是运行时真实存在的静态属性。这套合并规则会在 12.3 模块扩充与全局类型增强
里从原理上展开。
CommonJS:export = 与 import =
有些库的导出既不是 export default 也不是具名导出,而是直接给 module.exports 赋一个函数:
// node_modules/legacy-logger/index.js
module.exports = function createLogger(options) { /* ... */ };
module.exports.LEVELS = ["info", "warn", "error"];
这种「函数本身 + 挂属性」的形态,用 export = 表达:
declare module "legacy-logger" {
interface Logger {
info(message: string): void;
error(message: string, meta?: unknown): void;
}
function createLogger(options?: createLogger.Options): Logger;
namespace createLogger {
interface Options {
level?: "info" | "warn" | "error";
silent?: boolean;
}
const LEVELS: readonly string[];
}
export = createLogger;
}
消费方有两种写法:
// 写法一:CommonJS 风格,永远可用
import createLogger = require("legacy-logger");
// 写法二:需要 tsconfig 开启 esModuleInterop
import createLogger from "legacy-logger";
忘了开 esModuleInterop 就会撞上:
TS1259: Module '"/app/node_modules/legacy-logger/index"' can only be default-imported
using the 'esModuleInterop' flag and referencing its default export.
判断该用 export = 还是 export default 的方法很直接:看源码里是 module.exports = xxx 还是 exports.default = xxx。前者配 export =,后者配 export default。用错了会出现「导入的值是 undefined」这类只在运行时暴露的问题。
通配声明:资源文件
打包器允许 import "./style.css"、import logo from "./logo.svg",但 TypeScript 不认识这些后缀。用带 * 的模块声明兜住:
// typings/assets.d.ts
declare module "*.css" {
const classes: Record<string, string>;
export default classes;
}
declare module "*.svg" {
const url: string;
export default url;
}
带 ? 查询后缀的导入需要单独声明(注意引号里不能有歧义):
declare module "*?raw" {
const content: string;
export default content;
}
通配声明的匹配规则是「最长的前缀优先」。所以 "./a.css?inline" 会优先匹配 "*?inline"(如果存在)而不是 "*.css"。反过来,如果没有更具体的声明,"*.css" 也会匹配 "./a.css?inline"——因为 * 可以吃掉 ?inline 整段。这既是便利也是陷阱:
import inline from "./a.css?inline";
// 若只有 "*.css" 声明,inline 的类型是 Record<string, string>,
// 而运行时它其实是一个字符串。类型撒谎了。
让声明不撒谎
手写声明最大的风险是声明与实现脱节:库升级了,签名改了,声明没跟着改,而编译器不会发现——它只相信你写的声明。
按投入从低到高有三种手段:先用 tsc 从 JS 生成草稿骨架,再用类型测试锁住签名,最后用单测覆盖真实行为。
生成草稿是最省力的起手式:
tsc node_modules/legacy-logger/index.js \
--allowJs --declaration --emitDeclarationOnly --outDir typings-draft
生成的 index.d.ts 里未标注的参数会变成 any,但骨架、导出形态、方法名都是准的,改起来比从零写快得多。
类型测试的写法:
import { expectTypeOf } from "expect-type";
import { createLogger } from "legacy-logger";
expectTypeOf(createLogger).parameter(0).toEqualTypeOf<
{ level?: "info" | "warn" | "error"; silent?: boolean } | undefined
>();
expectTypeOf 在编译期比较类型,签名一旦漂移,tsc 直接报错。这套方案的完整用法见 15.2 类型测试(tsd/expect-type)
。
常见坑与错误信息
坑一:declare module 的名字与 import 的字符串不一致。 声明写 "legacy-logger",代码里 import ... from "legacy-logger/lib",匹配不上,照样报 TS7016。模块名必须逐字符相同。
坑二:在 declare module 块里写顶层 import。
// ❌ 这样写会改变整个文件的语义
import { Foo } from "./foo";
declare module "legacy-logger" {
export function use(foo: Foo): void; // Foo 在这里其实不可见
}
正确做法是把 import 写成 import("...").Foo 的内联形式,或用 import type 并在块内重新声明。
坑三:把声明写成了「覆盖」而不是「补充」。 如果目标包自带类型,你在脚本文件里写 declare module "express" 会把官方类型整个替换掉,丢失所有原有成员。正确姿势是模块扩充,见 12.3 模块扩充与全局类型增强
。
坑四:typings/ 没被 include 覆盖。 声明文件写得好好的,就是不生效——九成是 include / files 没包含它。
坑五:用 any 敷衍。 declare module "legacy-logger"; 能消掉红波浪线,但也把这一层的类型安全彻底关掉了。这属于技术债,应当留下 TODO 而不是当终点。
延伸阅读
- 类型测试与签名回归:/typescript-testing-type-safe/
- 从 API 定义自动生成类型:/typescript-api-type-generation/
- 进阶类型技巧(条件类型、模板字面量类型):/typescript-advanced-types/
小结
- 动手前先确认三条来源:
package.json的types、@types/xxx包、是否只是解析配置问题。 - 写声明的依据是源码与文档,不是猜测:先读导出形态,再读参数默认值与返回值。
- 三种粒度里,仓库内
typings/+declare module是绝大多数项目的正解;声明不要放进src/。 - 重载从具体到宽泛排列;可选参数用
?;返回this保留链式调用;类与命名空间合并可表达Class.Options这种嵌套类型。 module.exports = fn用export =,exports.default = fn用export default;配错会出现运行时undefined。- 资源文件用
*.css/*?raw通配声明,注意「最长前缀优先」和*吃掉查询串这两个规则。 - 声明会撒谎,所以要用
tsc --allowJs生成草稿、用类型测试锁住签名、用单测覆盖真实行为。
到这里,你已经能独立为任何无类型库补上可用的声明了。下一节讨论更微妙的一类操作:不去新建声明,而是往已有的类型上「长」东西——模块扩充与全局类型增强。
阅读导航:上一节:12.1 .d.ts 与 @types 机制 · 下一节:12.3 模块扩充与全局类型增强 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。