《TypeScript编程入门》12.2 为无类型库编写声明

本节讲的是 .d.ts 的实战编写:当一个库既没有自带类型、社区也没有 @types 包时,如何从 JavaScript 源码、README 与运行时行为反推出准确的签名。内容覆盖三种粒度的声明策略、函数重载与可选参数、类与命名空间的合并写法、CommonJS 的 export = 与 import =、资源文件的通配声明,以及用类型测试保证声明不撒谎。读完你能为任意内部老库补上可用的类型。

本节目标:读完这一节,你能判断一个库到底有没有类型、该走哪条路补类型;能从 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 };

读出来两个事实:

  1. module.exports 是对象,不是函数,也不是 export default;
  2. 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 而不是当终点。

延伸阅读

小结

  • 动手前先确认三条来源: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 模块扩充与全局类型增强 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes