likes
comments
collection
share

如何在JS项目中使用TS类型检查?

作者站长头像
站长
· 阅读数 16

前言

近期接手了一个JS项目,由于该项目创建的时间比较早,没有使用TS来进行开发;对于用惯了TS开发的我,一下子失去了类型检查和智能提示这两大功能,编码时很是有些不适应;如果贸然改造工具链去支持TS的话,会发生很多的不确定性问题,最终还是放弃了改造的念头。在研究过程中了解到想要使用TS的类型检查不一定非要改造构建工具链,使用JSDoc注释的方式也可以达到同样的目的。这要感谢VS Code(内置TS类型系统),它理解许多标准的JSDoc注释,并使用这些注释来提供丰富的智能提示,开发者可以选择使用JSDoc注释中的类型信息来对JavaScript进行类型检查,允许我们在常规的JS文件中利用TS的一些高级类型检查和错误报告功能。

类型系统

TypeScript中的类型系统在使用代码库时具有不同级别的严格性:

  1. 一个仅基于JavaScript代码推理的类型系统;
  2. 通过JSDoc在JavaScript中进行增量类型(辅助类型系统实现更准确的类型推导);
  3. 在JavaScript文件中使用 // @ts-check注释;
  4. 真正的TypeScript代码;
  5. 启用严格模式的TypeScript代码;

每一步都代表着向更安全的类型系统迈进,但并不是每个项目都需要这种级别的验证,因此可以通过JSDoc提供JS中的类型提示。

JSDoc注释

下面的内容概述了目前在JavaScript文件中使用JSDoc注解提供类型信息时支持的结构。注意,下面没有显式列出的任何标签(例如@async)都不支持(ref)。

类型

@type

你可以用@type标签引用(声明)类型。类型可以是:

  1. 基本类型,如stringnumber
  2. 在TypeScript声明中声明的,可以是全局的,也可以是导入的。
  3. 在JSDoc@typedef标签中声明的。

你可以使用大多数JSDoc类型语法和任何TypeScript语法,从最基本的string最高级的条件类型

/**
 * @type {string}
 */
var s;
 
/** @type {Window} */
var win;
 
/** @type {PromiseLike<string>} */
var promisedString;
 
// 可以指定具有DOM属性的HTML元素
/** @type {HTMLElement} */
var myElement = document.querySelector(selector);
myElement.dataset.myData = "";

@type 可以指定一个联合类型——例如,某个东西可以是字符串也可以是布尔值。

/**
 * @type {string | boolean}
 */
var sb;

你可以使用不同的语法来指定数组类型:

/** @type {number[]} */
var ns;
/** @type {Array.<number>} */
var jsdoc;
/** @type {Array<number>} */
var nas;

还可以指定对象字面量类型。例如,具有属性' a ' (string)和' b ' (number)的对象使用以下语法:

/** @type {{ a: string, b: number }} */
var var9;

你可以使用标准的JSDoc语法或TypeScript语法,使用字符串和数字索引签名指定类似map(map-like)和类似array的对象(array-like objects)。

/**
 * A map-like object that maps arbitrary `string` properties to `number`s.
 *
 * @type {Object.<string, number>}
 */
var stringToNumber;
 
/** @type {Object.<number, object>} */
var arrayLike;

前面两个类型等价于TypeScript类型 { [x: string]: number } and { [x: number]: any }编译器可以理解这两种语法。

你可以使用TypeScript或Google Closure语法指定函数类型:

/** @type {function(string, boolean): number} Closure syntax */
var sbn;
/** @type {(s: string, b: boolean) => number} TypeScript syntax */
var sbn2;

或者你可以使用未指定的Function类型:

/** @type {Function} */
var fn7;
/** @type {function} */
var fn6;

Google Closure其他类型也可以工作:

/**
 * @type {*} - can be 'any' type
 */
var star;
/**
 * @type {?} - unknown type (same as 'any')
 */
var question;
断言(强制类型转换)

TypeScript借用了Google Closure的强制转换语法。通过在任何带括号的表达式前添加@type 标记,可以将类型转换为其他类型。

/**
 * @type {number | string}
 */
var numberOrString = Math.random() < 0.5 ? "hello" : 100;
var typeAssertedNumber = /** @type {number} */ (numberOrString);

你甚至可以像TypeScript一样强制转换为const:

let one = /** @type {const} */(1);
导入类型

你可以使用导入类型从其他文件导入声明。这种语法是TypeScript特有的,不同于JSDoc标准:

// @filename: types.d.ts
export type Pet = {
  name: string,
};
 
// @filename: main.js
/**
 * @param {import("./types").Pet} p
 */
function walk(p) {
  console.log(`Walking ${p.name}...`);
}

导入类型可以在类型别名声明中使用:

/**
 * @typedef {import("./types").Pet} Pet
 */
 
/**
 * @type {Pet}
 */
var myPet;
myPet.name;

如果你不知道一个值的类型,或者它的类型很大,输入起来很麻烦,可以使用导入类型从模块中获取该值的类型:

/**
 * @type {typeof import("./accounts").userAccount}
 */
var x = require("./accounts").userAccount;

@param@returns

@param使用与@type相同的类型语法,但增加了一个参数名。该参数也可以通过在名称周围加上方括号来声明为可选的:

// Parameters may be declared in a variety of syntactic forms
/**
 * @param {string}  p1 - A string param.
 * @param {string=} p2 - An optional param (Google Closure syntax)
 * @param {string} [p3] - Another optional param (JSDoc syntax).
 * @param {string} [p4="test"] - An optional param with a default value
 * @returns {string} This is the result
 */
function stringsStringStrings(p1, p2, p3, p4) {
  // TODO
}

同样,对于函数的返回类型:

/**
 * @return {PromiseLike<string>}
 */
function ps() {}
 
/**
 * @returns {{ a: string, b: number }} - May use '@returns' as well as '@return'
 */
function ab() {}

@typedef, @callback, @param

你可以用@typedef定义复杂类型。类似的语法也适用于@param

/**
 * @typedef {Object} SpecialType - creates a new type named 'SpecialType'
 * @property {string} prop1 - a string property of SpecialType
 * @property {number} prop2 - a number property of SpecialType
 * @property {number=} prop3 - an optional number property of SpecialType
 * @prop {number} [prop4] - an optional number property of SpecialType
 * @prop {number} [prop5=42] - an optional number property of SpecialType with default
 */
 
/** @type {SpecialType} */
var specialTypeObject;
specialTypeObject.prop3;

你可以在第一行使用objectObject

/**
 * @typedef {object} SpecialType1 - creates a new type named 'SpecialType1'
 * @property {string} prop1 - a string property of SpecialType1
 * @property {number} prop2 - a number property of SpecialType1
 * @property {number=} prop3 - an optional number property of SpecialType1
 */
 
/** @type {SpecialType1} */
var specialTypeObject1;

@param 允许对一次性类型规范使用类似的语法。注意,嵌套的属性名必须以参数名作为前缀:

/**
 * @param {Object} options - The shape is the same as SpecialType above
 * @param {string} options.prop1
 * @param {number} options.prop2
 * @param {number=} options.prop3
 * @param {number} [options.prop4]
 * @param {number} [options.prop5=42]
 */
function special(options) {
  return (options.prop4 || 1001) + options.prop5;
}

@callback类似于@typedef,但它指定了一个函数类型而不是对象类型:

/**
 * @callback Predicate
 * @param {string} data
 * @param {number} [index]
 * @returns {boolean}
 */
 
/** @type {Predicate} */
const ok = (s) => !(s.length % 2);

当然,这些类型中的任何一种都可以使用TypeScript语法在单行@typedef中声明:

/** @typedef {{ prop1: string, prop2: string, prop3?: number }} SpecialType */
/** @typedef {(data: string, index?: number) => boolean} Predicate */

@template 泛型

可以使用@template标签声明类型参数。这允许你创建泛型的函数、类或类型:

/**
 * @template T
 * @param {T} x - A generic parameter that flows through to the return type
 * @returns {T}
 */
function id(x) {
  return x;
}
 
const a = id("string");
const b = id(123);
const c = id({});

使用逗号或多个标签声明多个类型参数:

/**
 * @template T,U,V
 * @template W,X
 */

还可以在类型参数名称之前指定类型约束。列表中只有第一个类型参数受到约束:

/**
 * @template {string} K - K must be a string or string literal
 * @template {{ serious(): string }} Seriousalizable - must have a serious method
 * @param {K} key
 * @param {Seriousalizable} object
 */
function seriousalize(key, object) {
  // ????
}

最后,你可以为类型参数指定一个默认值:

/** @template [T=object] */
class Cache {
    /** @param {T} initial */
    constructor(initial) {
    }
}
let c = new Cache()

@satisfies

@satisfies提供对TypeScript中satisfies后缀操作符的访问。用于声明一个值实现了一个类型,但不影响该值的类型。

/**
 * @typedef {"hello world" | "Hello, world"} WelcomeMessage
 */
 
/** @satisfies {WelcomeMessage} */
const message = "hello world"
        // const message: "hello world"
 
/** @satisfies {WelcomeMessage} */
const failingMessage = "Hello world!"
 
/** @type {WelcomeMessage} */
const messageUsingType = "hello world"
             // const messageUsingType: WelcomeMessage

用ES6语法声明的类。

class C {
  /**
   * @param {number} data
   */
  constructor(data) {
    // property types can be inferred
    this.name = "foo";
 
    // or set explicitly
    /** @type {string | null} */
    this.title = null;
 
    // or simply annotated, if they're set elsewhere
    /** @type {number} */
    this.size;
 
    this.initialize(data); // Should error, initializer expects a string
  }
  /**
   * @param {string} s
   */
  initialize = function (s) {
    this.size = s.length;
  };
}
 
var c = new C(0);
 
// C should only be called with new, but
// because it is JavaScript, this is allowed and
// considered an 'any'.
var result = C(1);

它们也可以声明为构造函数;请将@constructor@this一起使用。

属性修饰符

@public, @private, 和@protected 的工作方式与TypeScript中的public、private和protected完全相同:

// @ts-check 
class Car {
  constructor() {
    /** @private */
    this.identifier = 100;
  }
 
  printIdentifier() {
    console.log(this.identifier);
  }
}
 
const c = new Car();
console.log(c.identifier);
Property 'identifier' is private and only accessible within class 'Car'.
  • @public总是隐式的,可以省略,但意味着可以从任何地方访问属性。
  • @private表示一个属性只能在包含它的类中使用。
  • @protected意味着一个属性只能在包含类和所有派生子类中使用,而不能在包含类的不同实例中使用。

@public, @private, 和@protected 在构造函数中不起作用。

@readonly

@readonly 修饰符确保只在初始化期间写入属性。

// @ts-check
 
class Car {
  constructor() {
    /** @readonly */
    this.identifier = 100;
  }
 
  printIdentifier() {
    console.log(this.identifier);
  }
}
 
const c = new Car();
console.log(c.identifier);

@override

@override的工作方式与TypeScript中的相同;在覆盖基类方法的方法上使用它:

export class C {
  m() { }
}
class D extends C {
  /** @override */
  m() { }
}

可以在tsconfig中设置noImplicitOverride: true 来检查覆盖。

@extends

当JavaScript类扩展泛型基类时,没有传递类型参数的JavaScript语法。@extends标签允许这样做:

/**
 * @template T
 * @extends {Set<T>}
 */
class SortableSet extends Set {
  // ...
}

注意,@extends只与类一起工作。目前,构造函数无法扩展类。

@implements

同样,实现TypeScript接口也没有JavaScript语法。@implements标签的工作方式和TypeScript中的一样:

/** @implements {Print} */
class TextBook {
  print() {
    // TODO
  }
}

@constructor

编译器根据this-property赋值来推断构造函数,但如果你添加一个@constructor标签,你可以使检查更严格:

/**
 * @constructor
 * @param {number} data
 */
function C(data) {
  // property types can be inferred
  this.name = "foo";
 
  // or set explicitly
  /** @type {string | null} */
  this.title = null;
 
  // or simply annotated, if they're set elsewhere
  /** @type {number} */
  this.size;
 
  this.initialize(data);
  // warn: Argument of type 'number' is not assignable to parameter of type 'string'.
}
/**
 * @param {string} s
 */
C.prototype.initialize = function (s) {
  this.size = s.length;
};
 
var c = new C(0);
c.size;
 
var result = C(1);
// warn: Value of type 'typeof C' is not callable. Did you mean to include 'new'?

注意: 错误信息只显示在启用了JSConfig和checkJs的JS代码库中。

使用@constructorthis在构造函数C中被检查,因此您将得到关于初始化方法的建议,如果您向它传递一个数字,则会出现错误。如果您调用C而不是构造它,编辑器也可能显示警告。

不幸的是,这意味着同样可调用的构造函数不能使用@constructor

@this

The compiler can usually figure out the type of this when it has some context to work with. When it doesn’t, you can explicitly specify the type of this with @this:

当编译器有上下文需要处理时,它通常可以确定this的类型。如果没有,你可以用@this式指定this的类型:

/**
 * @this {HTMLElement}
 * @param {*} e
 */
function callbackForLater(e) {
  this.clientHeight = parseInt(e); // should be fine!
}

文档

@deprecated

当一个函数、方法或属性被弃用时,你可以用/** @deprecated */JSDoc注释来让用户知道。该信息会显示在完成(提示)列表中,并作为编辑器可以专门处理的建议诊断。在像VS Code这样的编辑器中,不推荐的值通常以这样的划线样式显示。

/** @deprecated */
const apiV1 = {};
const apiV2 = {};
 
apiV;
// ~~apiV1~~
// apiV2

@see

@see 可以让你链接到程序中的其他名字:

type Box<T> = { t: T }
/** @see Box for implementation details */
type Boxify<T> = { [K in keyof T]: Box<T> };

一些编辑器会把Box变成一个链接,以便于跳转到那里和返回。

@link

@link 类似于@see只是它可以在其他标签中使用:

type Box<T> = { t: T }
/** @returns A {@link Box} containing the parameter. */
function box<U>(u: U): Box<U> {
  return { t: u };
}

其他标签

@enum

@enum标签允许您创建一个对象字面值,其成员都是指定类型。不像JavaScript中的大多数对象字面量,它不允许其他成员。@enum旨在与Google Closure的@enum标签兼容。

/** @enum {number} */
const JSDocState = {
  BeginningOfLine: 0,
  SawAsterisk: 1,
  SavingComments: 2,
};
 
JSDocState.SawAsterisk;

注意@enum与TypeScript的enum完全不同,而且比它简单得多。然而,与TypeScript的枚举不同,@enum 可以是任何类型:

/** @enum {function(number): number} */
const MathFuncs = {
  add1: (n) => n + 1,
  id: (n) => -n,
  sub1: (n) => n - 1,
};
 
MathFuncs.add1;

@author

你可以用@author来指定项目的作者:

/**
 * Welcome to awesome.ts
 * @author Ian Awesome <i.am.awesome@example.com>
 */

记得用尖括号把电子邮件地址括起来。否则, @example将被解析为一个新标记。

其他支持的范例

var someObj = {
  /**
   * @param {string} param1 - JSDocs on property assignments work
   */
  x: function (param1) {},
};
 
/**
 * As do jsdocs on variable assignments
 * @return {Window}
 */
let someFunc = function () {};
 
/**
 * And class methods
 * @param {string} greeting The greeting to use
 */
Foo.prototype.sayHi = (greeting) => console.log("Hi!");
 
/**
 * And arrow function expressions
 * @param {number} x - A multiplier
  / let myArrow = ( x ) =>  x  x;
 
/**
 * Which means it works for function components in JSX too
 * @param {{a: string, b: number}} props - Some param
 */
var fc = (props) => <div>{props.a.charAt(0)}</div>;
 
/**
 * A parameter can be a class constructor, using Google Closure syntax.
 *
 * @param {{new(...args: any[]): object}} C - The class to register
 */
function registerClass(C) {}
 
/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn10(p1) {}
 
/**
 * @param {...string} p1 - A 'rest' arg (array) of strings. (treated as 'any')
 */
function fn9(p1) {
  return p1.join();
}
Try

不支持的范例

对象字面值类型中属性类型的后缀=不能指定可选属性:

/**
 * @type {{ a: string, b: number= }}
 */
var wrong;
/**
 * Use postfix question on the property name instead:
 * @type {{ a: string, b?: number }}
 */
var right;

Nullable类型只有在strictNullChecks开启时才有意义:

/**
 * @type {?number}
 * With strictNullChecks: true  -- number | null
 * With strictNullChecks: false -- number
 */
var nullable;

typescript原生语法是一个联合类型:

/**
 * @type {number | null}
 * With strictNullChecks: true  -- number | null
 * With strictNullChecks: false -- number
 */
var unionNullable;

Non-nullable 类型没有意义,被当作它们的原始类型对待:

/**
 * @type {!number}
 * Just has type number
 */
var normal;

与JSDoc的类型系统不同,TypeScript只允许你将类型标记为包含null或不包含null。没有显式的不可空性——如果strictNullChecks打开,那么number是不可空的。如果它是off,那么number是可空的。

混合用法

混合用法是指.d.ts类型声明文件和JSDoc注释同时使用的方式。由于JSDoc注释的表达力有限,有些类型检查时出现的问题没法很好的解决,需要借助.d.ts类型声明文件来解决,或者开发人员习惯了使用TS的方式声明类型,所以日常使用都是这么混合着使用。日常开发中涉及到的.d.ts类型声明文件基本上有两种:全局.d.ts类型声明文件和模块.d.ts类型声明文件,有关类型声明文件开发方式可以参考TS官方文档

参考资料