基于JSDoc实现TypeScript类型安全的实践报告

在FEDay 2023中我讲了《从JS到TS无缝迁移的实践报告》【视频在这里在这里】,是将一个传统的JS项目(mochajs/mocha)迁移到TypeScript环境的全程。其中提到了一件事情,就是“可以通过JSDoc/TSDoc来生成.d.ts”,从而实现TypeScript的类型安全检查。

有同学希望我能将这个过程也复述一下,一方面有“从JS到TS迁移”作为对照,可以看到二者间的差异,以及评估这个方案的代价,另一方面也可以作为一份很好的实践参考,探探路也顺道解决一些问题。于是简单整理如下。

签出js项目,并简单生成.d.ts

可以直接在命令行来生成.d.ts,以及检查和评估当前项目。如下:

# 签出代码
> git clone https://github.com/mochajs/mocha && cd mocha

# 安装TypeScript
> npm install -D typescript

# 方法1:生成.d.ts到./types目录(为每一个.js生成一个对应的.d.ts)
> npx tsc ./index.js ./lib/**/*.js --allowJs --target es2018 --moduleResolution node \
    --emitDeclarationOnly --declaration --outDir "./types"

# 方法2:生成打包的.d.ts(将列表中所有文件的.d.ts打包到一个)
> npx tsc ./index.js ./lib/**/*.js --allowJs --target es2018 --moduleResolution node \
    --emitDeclarationOnly --declaration --outFile "./index.d.ts"

# 方法3:生成打包的.d.ts(指定唯一的入口文件,并将该文件及其关联的全部模块打包到同一个.d.ts)
> npx tsc ./index.js --allowJs --target es2018 --moduleResolution node \
    --emitDeclarationOnly --declaration --outFile "./index.d.ts"

注意这里直接在命令行上指定了文件名列表,因此不需要使用目录中的tsconfig.json。上述命令总是会根据JSDoc来尽量生成可用的.d.ts文件。注意:./lib/**/*.js没有添加双引号,是借助shell来处理了通配符,这在DOS命令行上是不行的。

参数说明:

  • --allowJs:允许tsc处理.js文件并识别其中JSDoc的类型信息。
  • --target:目标环境的版本,编译和语法检查时会用到(会同步影响--lib参数)。
  • --declaration:生成.d.ts文件。
  • --moduleResolution:指定查找模块的方式,这里置node指的是使用传统的Node.js风格。
  • --emitDeclarationOnly:只生成.d.ts(不生成.d.ts.map文件)。
  • --outFile or --outDir:指定输出的单个文件名(bundle),或者每个.js对应一个.d.ts并输出到指定目录。

这三种方法输出.d.ts的结果还是不错的,但也只是“大致可用”而已。这有两个方面的原因:

  1. 这样输出的.d.ts未经查错的,因此尽管“有类型信息”,但可能某些信息访问起来会出错。
  2. 如果源码是CommonJS风格的,那么.d.ts模块引用关系通常会存在错误(ESM模块风格会较好一些)。

具体解决问题的方法后面会讲。

简单查错

如果要查错,可以使用下面的命令:

# 简单查错
> npx tsc ./index.js ./lib/**/*.js --allowJs --checkJs --strict --noEmit --target es2018 --moduleResolution node
...
Found 1529 errors in 51 files.
Errors  Files
  ...

参数说明:

  • --checkJs:允许利用JSDoc对.js文件做类型检查
  • --strict:在类型检查(例如使用了--checkJs)时使用严格模式
  • --noEmit:不生成任何文件,只查错。

这样就可以简单地检查.js源代码中可能隐含的类型错误。其中的一部分是通过JSDoc引入类型信息时的错误(例如找不到某个类型);另一部分则是静态检查.js源代码的错误,例如试图访问某个不存在的属性。这些检查与在TypeScript开发中的类型检查是类似的,并且报出的也是TS:xxxx这样的错误代码,只不过依赖的是JSDoc中的类型信息而已。

如此一来,你就会看到大量的错误提示(以mochajs/mocha项目来说,有1529个错误)。

在VSCode中开发

可以在这个项目根目录中塞入一个tsconfig.json文件,然后就可以在VSCode中开发了,既可以语法提示,也方便管理错误和基于.js来开发调试。这看起来就很令人心动,简单如下:

# 并初始化为TypeScript项目
> npx tsc --init --allowJs --checkJs --strict --noEmit --module commonjs --target es2018

参数说明:

  • --noEmit:这里使用--noEmit的目的是将该配置是说明不需要转译到.js文件(以避免在开发过程中第三方工具尝试生成.js而导致覆盖原始文件错)。

这是一个适合VSCode开发的基本配置,在VSCode中打开该项目目录时,就会自动启用完整的类型检查功能。——需要说明的是,在VSCode中开发,与是否生成.d.ts并不是直接相关的。例如这个项目,在不做任何修改时也能生成.d.ts文件,但试图通过JSDoc来替代TypeScript,在启用了--checkJs之后能做类型检查,但开发过程中却并不需要生成.d.ts文件(--noEmit配置为true)。

接下来我就具体讲一下在JSoc中(部分地)修复这些类型错误的方式,以及进一步解决上面提到的.d.ts的两个问题的方法。

经典的错误1:模块引用

JSDoc并没有引用外部模块的能力。比如说下面这个:

/**
 * Creates an error object to be thrown when done() is called multiple times in a test
 *
 * @param {Runnable} runnable - Original runnable
 * ...
 */
function createMultipleDoneError(runnable, originalErr) ...

显然,这里的runnable是声明在其它模块中的类型。在.ts中,可以通过import/import type来引入类型并声明它,但JSDoc中通常只写类型名,这就导致那些从外部模块中引用的类型会出个错误。如下图:
基于JSDoc实现TypeScript类型安全的实践报告_第1张图片
解决这类问题的方法,是在注释中写import ...。——是的,感觉就是把代码写进注释一样。如下:

 * @param {import("./runnable")} runnable - Original runnable

这个写法是直接使用了动态导入import()的语法,这里的./runnable是缺省导出Runnable类所以简单引用一下就行。如果模块是缺省导出了一个名字空间,那么就会复杂一点。例如utils.js模块:

 * @param {import("./utils").escape} fn - callback

那么当参数fn就可以引用到Utils.escape()这个函数的类型信息。当然,在实际使用JSDoc来开发时,为了避免这种“随时随地的import()”,所以也会把它们单独提取出来做typedef。例如:

/** 
 * 外部模块中的类型引用
 * @typedef {import("./runnable")} Runnable
 * @typedef {import("./utils").escape} callbackFn
 * ...
*/

在VSCode中使用的效果如下:
基于JSDoc实现TypeScript类型安全的实践报告_第2张图片

经典的错误2:继承性

继承问题也是最常遇到的,如果是在ES6中使用的class声明,那么TypeScript会根据extends子句来自动推断。但是如果你使用特殊的方法来实现继承,例如setPrototypeOf(),或者是使用ES6之前原型继承,又或者是声明构造器函数等等,那么JSDoc中能否声明这种继承性呢?
首先,需要排除掉@extends声明。这个JSDoc的语法会要求作为类的注释,所以既不能在构造器上,也不能写给一个普通的(原型继承的)对象。
然而一旦排除这个声明,那么接下来的事情就变得复杂了。例如在error.jscreateMultipleDoneError()的具体实现:

function createMultipleDoneError(runnable, originalErr) {
  var err = new Error(message); // 从父类创建实例
  err.code = constants.MULTIPLE_DONE;
  err.valueType = typeof originalErr;
  err.value = originalErr;
  return err;
}

在ES6之前,几乎所有的Error子类的实现——以及所有的子类类型——的实现方法都是如此:从父类创建实例(用作this),然后抄写属性。然而err对象既然是子类的实例,那么它的类型应该是子类的,而不是父类Error;并且由于err创建成为父类实例,所以后续的code等子类中的成员就不能正常访问了。所以这段代码会有如下类型错误:
基于JSDoc实现TypeScript类型安全的实践报告_第3张图片
所以需要创建一个子类类型,例如MultipleDoneError,这个类型需要派生自Error,并且具有code / valueType / value等成员。并且,在这个项目中这些实例是通过工具函数创建出来的,所以不需要声明类,也不需要声明构造器。所以,通常能在网上找到的“使用JSDoc派生子类”的方法就失效了,例如:

/**
* @typedef {object} MultipleDoneError
* @extends {Error}
* @property {constants.MULTIPLE_DONE} code - Error code
* @property {string} valueType - type of that value
* @property {Error | undefined} value
*/

/** ... (函数界面声明,略)*/
function createMultipleDoneError(runnable, originalErr) {
  /** @type {MultipleDoneError} */ 
  var err = new Error(message);
  ...
}

在这个示例中,MultipleDoneError类型的声明是正确的,并且也符合JSDoc的语法,而在VSCode中显示这个err的类型时:
基于JSDoc实现TypeScript类型安全的实践报告_第4张图片
这是由于@extends这个标记在这个上下文中不支持[*1],所以推断成了MultipleDoneError原本的类型object
唯一有效的方法是采用TypeScript中的“交叉类型(Intersection Types)”语法,用类型混入来替代继承。——当然,事实上传统的原型继承(类抄写)在概念上也确实更近似于“类型交叉/混入”。如下:

/**
* 方法一:直接声明交叉类型
* @typedef {Error & {
*  code: typeof constants.MULTIPLE_DONE, // Error code
*  valueType?: string, // type of that value
*  value?: Error | undefined
* }} MultipleDoneError
*/

/**
* 方法二:声明两个独立类型,并交叉
* @typedef {object} BaseMultipleDoneError
* @property {typeof constants.MULTIPLE_DONE} code - Error code
* @property {string} valueType - type of that value
* @property {Error | undefined} value
* @typedef {Error & BaseMultipleDoneError} MultipleDoneError
*/

上述两种方法得到的类型名MultipleDoneError都是可用的,方法一完全使用了TypeScript的语法,但与JSDoc与TypeScript语法混用看起来有些繁琐,方法二稍好些,是完全的JSDoc语法,但是会多 一个中间类型。并且,——需要注意的是——所有这两种方法的结果,都只是使得子类能声明出正确的成员列表,而并不能“显式的”表明继承关系。
接下来,按照JSDoc中的“常规用法”,简单地用如下语法将类型信息添加给变量err,如下:

/** @type {MultipleDoneError} */
var err = new Error(message);
...

但是很不幸,这会导致一个赋值时的类型错误。——new Error()创建的结果类型,不能赋给MultipleDoneError类型。简单地说:父类的实例,不能赋给子类。接下来讲这个问题。

注*1:当一个注解不支持时,它会导致后续其它注解也失效。而这一点在VSCode中反映不出来,所以表面看起来注释没有任何异常或错误提示,但代码中的类型推断结果却是object

经典的错误3:类型断言(强制类型)

如果使用ES6之后的类声明,当然就没有这个问题。但在ES6之前,这种构造或创建实例的方法是常规操作,TypeScript中也有简单的应对方法。例如将Error()声明成泛型函数,如下:

// signatures
interface MochaError {
  new <T>(message: string): T;
  <T>(message: string): T;
}

const Error = function <T>(message: string) {
  return  new globalThis.Error(message) as T;
} as MochaError;

var err = new Error<MultipleDoneError>(message);
...

这样,在实现泛型Error<>时使用了“… as T”做类型断言,这使得Error()总是能返回预期的类型(例如MultipleDoneError类型),于是TypeScript就可以将它推定为err变量的类型。然而在JSDoc中没有“断言”这种类型表达式语法,取而代之的是在表达式上的注释语法。如下:

var err = /** @type {MultipleDoneError} */(new Error(message));
...

这在语义上相当于new Error(message) as MultipleDoneError这样的类型断言。只不过JSDoc中的类型都是通过特定语法声明在注释中,没有用到泛型而已[*2]。效果如下:
基于JSDoc实现TypeScript类型安全的实践报告_第5张图片
而在查看MultipleDoneError类型时,将显示为下面这样:
在这里插入图片描述

注*2:在JSDoc中也有类似泛型的机制,称为“模板(@template )”。

.d.ts文件的使用问题

现在讨论一下之前生成的三种.d.ts文件有什么用,以及如何使用的问题。

这些生成的.d.ts与你在@types/xxx可见的那些有着非常大的区别,“直接拿来就用”的想法可以休也。大概说明如下:

  • 方法一:生成的.d.ts按目录结构放在./types目录中,与源码中的.js一一对应。是“勉强可用”的版本,但离交付结果尚远。
  • 方法二、三:生成的.d.ts没有本质上的区别,只是由于方法三使用了“入口点(EntryPoint)”文件,所以不在依赖树中的代码未被打包。
    如果要使用./types目录下的定义文件,那么可以将它的入口写到package.json,例如:
"types": "./types/index.d.ts"

由于对导入者来说,目标模块(例如"mocha")的package.json定义了一个包,以及该包的结构,因此"types"定义的是这个包对外部的界面(也就是index.js)的类型定义文件。——注意这里的讲述,意思是“只有这个入口文件有类型信息”。所以严格来说,导入者只能访问index.js中导出的东西,而不能访问其内容的东西。那么,下面的代码怎写呢?

const Mocha = require('mocha');
let ctx = new Mocha.Context(); // 注意这里的ctx的类型信息
let mocha = new Mocha(ctx, ...); // 在上下文(ctx)中创建mocha实例
...

注意这里的ctx应该是"mocha"包中某个类的类型,而Mocha.Context()在这里只是被导出的构造器,所以在TypeScript中,"mocha"包需要通过名字空间来导出Context的类型信息,以便用户代码在外部使用。但是在.js中没有这个机制,因此在对应的JSDoc里也没有,对应的.d.ts里还也就同样没有。

所以严格来说上面的代码是完不成的。但是VSCode取了点巧,如果你当前在写的是.ts代码,它会“帮助你”在./types中去“发现(resolve)”那些定义文件。所以,当你使用了一个不存在的类型时,它会尝试着去添加相应的引用。例如:
基于JSDoc实现TypeScript类型安全的实践报告_第6张图片
当你添加@type {Context}时,VSCode会自动添加import Context ...行。——当然,既然这里是.ts代码,那么写成下面这样,VSCode也是同样会自动完成import ...语句的:

context ctx: Context = new Mocha.Context();  // 在给ctx添加类型时,VSCode会自动完成import语句的添加

但是这仅针对.ts文件,如果是.js,就需要自己在JSDoc中添加下声明了:

/**
 * @typedef {import("mocha/types/lib/context")} Context
 */

说说这里的包路径(mocha/types/llib/context)的问题。如果是习惯用AMD或UMD的同学,这样的包路径应该不陌生。因为这些模块风格中,模块是打包在一个文件中的,每个模块(各个.js)就用这样的字符串定义在同一个Bundle里面。显然,考虑到“一个包”的性质,将所有的".d.ts"打包到一个Bundle中是合适的做法,这样就可以将./index.d.ts与包的根目录中的./index.js放在一起了。[*3]

注*3:这一点的细节容后再议,这里需要先指出的是:打包,改变了输出的index.d.ts的性质,但并没有对当前的包(例如这里的Mocha)带来特别的好处。不过这里指用TypeScript自己打包器的输出结果,在'./types'中的多个文件,与单独的Bundle在这里是有着本质区别的。

不可能完成的任务:打包.d.ts

就路径的表达上,上面的方法2和3的打包结果是AMD/UMD是一样的。例如在它们生成的index.d.ts中:

declare module "lib/utils" {
    export { inherits };
    export function escape(html: string): string;
    ...
}

declare module "lib/pending" {
    export = Pending;
    ...
}

...

declare module "index" {
    const _exports: typeof import("lib/mocha");
    export = _exports;
}

但这个打包的index.d.ts其实并不是一个“模块的包”,而是一个在TypeScript中称为环境(Ambinet modules)的东西:你不能在TypeScript中用import来引用它,而是需要使用“三斜杠(///)”来引入并装载在当前模块的顶层或全局。——换言之,你可以认为package.json中的"types": ...有两个作用,一是指示.d.ts文件的位置,二是指示VSCode中的TypeScript在语法分析时将这个包作为哪种东西(环境文件/一般模块)处理。

这是因为在TypeScript中有三种语义上的“模块”:1、标准ESM模块;2、非ESM标准模块(例如CJS/AMD/UMD);3、TypeScript的模块。对此,TypeScript有一个“简单粗暴”的识别模块的原则:文件中有export/import语句的就是模块,否则,就不是。所以TypeScript的模块(第3种)扩展了ESM模块(第1种),并全兼容它。但是对第2种,也就是使用CJS/ADM/UMD等模块风格的.js文件,TypeScript能识别和处理,但在概念上却不认为它们是模块。——所以也就不能按照“模块”来处理。

上面的看起来有些“绕”,但它的意义在于说明:正是因此,TypeScript打包的index.d.ts“Mocha"处理成了环境模块(Ambinet modules),而不是一般模块。因此在最终输出的"./index.d.ts"中,其最外层也就并没有“import/export”语句。——它是一个“.d.ts的包”,但不是一个模块。

“不是模块”带来了很多问题,比如你不能在这个文件中做多重导出:

// 假设这是在index.d.ts文件内
...
export { Mocha, Context, Hook, ... }

因为这是“.d.ts的包”,是环境模块(Ambinet modules),所以Mocha, Context, Hook, ...这些名字都不存在,也就不能导出。于是也还有一种建议,在这个模块index.d.ts末尾强制加上一个“export {}”,让TypeScript当成包来处理。——如果你这样做了,很快就会发现,由于包的路径发现策略跟环境模块不同,所以一旦加入这行语句,那么一些原本还能找到的类型定义就找不到了,这个.d.ts文件会抛出更多的错误(例如:Cannot find module ‘lib/cli/options’ or its corresponding type declarations.ts(2307))。

考虑到我们在这里并不实际使用ts开发,也不会去编译'.ts'文件来产生实际运行代码,所以也可以写一个“专门用来导出的./index.ts”文件,它只用于编译产出“./index.d.ts”,例如:

// 手写的index.ts
import Mocha from './lib/mocha';
import Context from './lib/context';
...

export {Mocha, Context, ...};
export default Mocha;

或者使用如下语法:

// NOTE: 需要先在当前项目中使用`npm instal @types/node`来安装Node.js支持

// 手写的index.ts
export const Mocha: typeof import("./lib/mocha") = require('./lib/mocha');
...

export default Mocha;

比之前稍好一些的是:总算可以在index.d.ts中包括多个导出了。

# 编译index.ts(注意--allowSyntheticDefaultImports参数用于让import语句兼容性支持“导出赋值”)
> npx tsc ./index.ts --allowJs --module es2020 --target es2018 --moduleResolution node \
    --allowSyntheticDefaultImports --emitDeclarationOnly --declaration --outFile "./dist/index.d.ts"

但是使用tsc打包出来的’./index.d.ts’仍然会是环境模块。而接下来的工作,就需要第三方工具出场了。——如果你没有兴趣看下去,那么我可以先告诉你结果:

没有工具能在使用export =语法的传统CJS项目上生成一个.d.ts的打包。

首先要排除一类“不能忽略错误”或“要强制开启–checkJs”的工具,因为我们这里是使用JSDoc来生成类型信息,——就目前来说,它还有1700多处Bug修呢。这就直接干掉了dts-bundle-generator、@hyrious/dts。不支持编译’.js’的,比如说在调用tsc时不发送--noEmit / --allowJs参数的也不能用。此外,还有一大类的工具是基于rollup中的rollup-plugin-dts插件的,因为这个插件不支持子名字空间(namespace child (hoisting)),这导致多个面向commonjs的导出赋值也不能使用了,因此这类工具也全都用不了。——在commonjs风格的.js中不能用,但在esm模块风格中的不受影响。[*4]

总而言之,因为mochajs/moha是一个“传统的.js项目”,所以……许许多多的第三方工具都牺牲了。

与我们的目标相近似的,有一个rollup的插件,称为rollup-plugin-flat-dts,它的作用是将所有的delcare module ...摊平在同一个模块中。这样一来,所以在子目录中的(例如"lib/reporters/base")模块都将展开在根模块(例如"mocha")中,然而它无视了cjs/esm等模块语法的差异,当这些模块展开在一起时,就出现了“多次声明默认导出(export default …)”这样的事情。因此也不可用。
如果你只是打算了解一个这个话题(打包".d.ts"文件)的热度和复杂程度,可以尝试用这个官方issues开始。

注*4:我一直很看好的tsup,也因为使用rollup-plugin-dts插件在这里倒下了。

——上面提到的每一个问题(小目标),都是在TypeScript圈子里广泛讨论并有“无数的”解决方案的话题。然而大都无有结论,并最终缺乏面向低版本代码的适用性。

总结

在项目中使用JSDoc来获得类型支持以及查错,并最终输出外部的,或第三方工具可用的.d.ts是可行的。这对于不太复杂的项目尤其适用,并且它还可以同步产生规范的技术文档支持,因此是一个不错的实践。但是JSDoc描述复杂的类型(例如泛型、接口等)时力不从心,有些时候还需要反过来使用TypeScript定义全局声明(global.d.ts),这反而使得技术栈变得复杂。

使用JSDoc与TypeScript所能遇到的问题,相比起来只多不少(例如前面提到过的1700+错误),无论哪种方案,在“定义类型并保证类型安全”这件事上的工作量都是相当的,所以最终选用哪一种,往往会是“喜欢怎样的语法”的问题。使用JSDoc的方式并不会使代码变得“更整洁”,其实多数情况下,TypeScript在函数内的都是可以类型推定的,并不会带来阅读上的压力。但是,直接使用TypeScript的社区庞大,大多数问题都可以快速找到解决方案,而JSDoc在这方面就很难,而且多数时候还是会绕回到TypeScript社区的圈子。——使用TypeScript的那些工具集,或者从TypeScript高手那里得到建议。

关于Types in JSDoc,Gil Tayar有四点总结(参见这里)非常简明扼要,简述如下:

  1. JSDoc 注释中的类型定义有时笨拙且冗长。
  2. 无法在JSDoc中完成所有操作,所以有时候需要手写.d.ts。
  3. 在模拟TS的(x as …)这个类型推断语法时,嵌入在表达式中的JSDoc很丑。
  4. 相关的讨论太少,许多问题还缺乏最佳实践,甚至没被发现。

最后我再补充一点,就是对旧的(传统的CommonJS模块风格)的项目和语法支持太差,这主要是TypeScript相关工具集的问题(例如webpack/rollup等),但在TypeScript下面有解决方案,而到了JSDoc中就没法处理了。因此,本文中的“打包.d.ts”这一目标从来没被实现过。——这里有两种打包方案,一种是直接从.js/.ts开始,由打包工具完成编译到打包的全程;另一种是先由tsc生成各个文件对应的.d.ts,然后由第三方工具拼合打包。所有工具,都无法完全两种方案之任一。

参考

  • TypeScript 官方手册 @see https://www.typescriptlang.org/docs/handbook/declaration-files/dts-from-js.html
  • 一个非常大的参考入口 @https://github.com/DavidWells/types-with-jsdocs 【推荐】
  • 无 Bundler 纯 JS 环境下的简易 TS 实践 @https://zhuanlan.zhihu.com/p/664653918 【这篇文章走了许多弯路,但很有趣】
  • 一些Bundler之间的比较以及推荐 @https://github.com/timocov/dts-bundle-generator/discussions/68
  • 一些type in js项目 @https://github.com/voxpelli/types-in-js/discussions/11 【这个repo有一简要指引,不错】
  • JSDoc继承声明的完讨论 @https://github.com/jsdoc/jsdoc/issues/1199 【复杂。长。】

你可能感兴趣的:(typescript,安全,javascript)