skip to content
OnionTalk

使用Typescript给JavaScript做静态类型检查

TypeScript,作为JavaScript的超集,给JavaScript带来了强类型这个非常强大的特性,给前端的开发以及重构带来了很大的便利性。但是,即使TypeScript现在已经可以直接使用用JavaScript编写的模块了,有很多遗留项目想要立马迁移到TypeScript也并非易事。但是好消息是,TypeScript在2.3版本引入了Js Type Checking,从此,不需要任何对于已有代码的侵入,你也可以很好的享受TypeScript带来的一些特性了。

从迁移 TypeScript 说起

笔者所说的这个项目,是一个运行了接近五年的老项目,代码横跨 es3 到 es6。而且代码风格由于项目的人员流动也各异。由于项目人数越来越多,以前留下的技术债造成的危害也越来越大。而重构,在这样一个臃肿且混乱的项目中显得举步维艰。由于 JS 的动态性,有的时候你甚至不知道这个方法是某个类下的实例方法还是 JavaScript 原生或是 JQuery 所提供的方法。 这个时候,TypeScript 由于其提供的强类型能很好的规范代码,方便开发人员进行日常开发和重构,开始进入了我们的视野。 但是马上我们面对上了另一个难题,那就是,我们只想享受 TypeScript 强类型带来的类型推导的优势,并不想花大功夫去把整个代码库全部重构成 TypeScript。 不过好在,TypeScript 从 2.3 版本后就开始支持使用 JsDoc 的语法,以 comment 的形式给 JavaScript 文件提供强类型的支持。Type Checking JavaScript Files · TypeScript

如何开始使用 Type Checking

首先你需要在你需要检查的 JavaScript 的文件头部显式的加上如下 comment

// @ts-check

对变量进行类型检查

如果你需要声名一个变量的类型,你可以这样

/** @type {number} */
let x;

x = 0; // OK
x = false; // Error: boolean is not assignable to number

当你尝试给 x 赋一个 bool 值时,编辑器会提示你类型冲突。

对函数进行类型检查

当然某些时候我们最需要的其实是对于函数签名和返回值的强类型声名,以便我们在其他地方使用的时候不会传入错误的参数或者使用类型错误的函数返回值。TypeScript 提供了三种语法来声名函数的类型.

// jsdoc standard syntax
// 声明一个函数
/**
 * @param {string} foo
 * @param {string} bar
 * @returns {string}
 */

function test(foo, bar) {
  return `${foo} and ${bar}`;
}

// closure syntax
// 声明一个函数表达式
/**
 * @type {function(string, string): string}
 */

let test; // test 必须符合定义的函数签名
test = (foo, bar, foobar) => '123'; // 报错 函数不符合定义的签名
test = (foo, bar) => 1; // 报错 函数返回值不符合

// typescript like syntax
// 声明一个函数表达式
/**
 * @type {(foo:string, bar:string) => string}
 */

let test; // test 必须符合定义的函数签名

在日常使用中,如果你想声明一个函数,对于函数做类型检查,你需要使用 @params @returns这种 declaration 语法。 如果你想确定一个函数表达式的签名,你需要使用 @type的语法。

对于 @type 语法, 从表现力上,我个人更偏好typscript like syntax因为表达力最强。但是可惜的是这种语法在 webstorm 上会被认为是非法语法。

自定义类型

自定义类型有点类似于定义 TypeScript 中的 Interface,TypeScript 同样支持两种语法来自定义类型。

/**
 * @typedef {Object} Human
 * @prop {string} name
 * @property {number} age
 * @prop {(string) => void} talk
 */

/** @type {Human} */
let Human;

/**
 * @typedef {{name: string, age:number, talk: (string) => void}} Person
 */

/** @type {Person}*/
let person;

需要注意的是,第二种语法在 webstorm 中也不被认为只一个合法的语法。

声明一个 class

对于类的 property 的类型,可以很轻松的进行检查

// 有初始化值,可以依赖类型推导
class C {
  constructor() {
    this.constructorOnly = 0;
    this.constructorUnknown = undefined;
  }
  method() {
    this.constructorOnly = false; // error, constructorOnly is a number
    this.constructorUnknown = 'plunkbat'; // ok, constructorUnknown is string | undefined
    this.methodOnly = 'ok'; // ok, but y could also be undefined
  }
  method2() {
    this.methodOnly = true; // also, ok, y's type is string | boolean | undefined
  }
}

// 无初始化值,显式声明.
class C {
  constructor() {
    /** @type {number | undefined} */
    this.prop = undefined;
    /** @type {number | undefined} */
    this.count;
  }
}

let c = new C();
c.prop = 0; // OK
c.count = 'string'; // Error: string is not assignable to number|undefined

对于类的实例方法,你需要对于函数做出声明.

class Foo {
  /**
   * @param {string} binggo
   * @returns {string}
   */
  bar(bingo) {
    return bingo;
  }
}

let test2 = new Foo();
test2.bar(1); // Error Emit.

其他的一些特性

可选参数

/**
 * @param {string} [somebody] - Somebody's name.
 */
function sayHello(somebody) {
  if (!somebody) {
    somebody = 'John Doe';
  }
  console.log('Hello ' + somebody);
}

sayHello();

类型 union

/**
 * @type {(string | boolean)}
 */
var abc;

枚举

jsdoc 的枚举和其他语言的枚举或者 typescript 的枚举有所不同,更多的是起到描述属性值为同一种类型的 object 的作用

/**
 * @enum {number}
 */

const JSDocState = {
  BeginningOfLine: 0,
  SawAsterisk: 1,
  SavingComments: 2,
};
// all property value should be number

/** @enum {function(number): number} */
const Math = {
  add1: (n) => n + 1,
  id: (n) => -n,
  sub1: (n) => n - 1,
};
// all property value should match the function signature

内联 object

/** @type {{ foo1: string, foo2: number }} */
let foo; // foo will be declared as an object, foo.foo1 must be a string, foo.foo2 must be a number.

type checking React

// stateless component, cause it's just a function, we can declare the props as parameter directly.

/** @param {{className: string, content: string}} props */

const Button = (props) => <button class={props.className}>{props.content}</button>;

// stateful component. It's just a class, so we treat it as a class

/**
 * @extends {Component<{className: string, content: string)>}
 */

class Button extends Component {
  // ...
}

三方类型

三方类型在这里既可以是 npm 提供的 @types包,也可以是自己在项目中通过 TypeScript 定义的类型。 只要在 typeRoots 这个编译器选项中指明即可。

如何和项目做集成

IDE 和编辑器集成

项目以使用 vscode 和 webstorm 为主,但是理论上,只要能支持TypeScript Language Server Protocal的编辑器都不会出现集成上的问题。

vscode

对于 vscode,不需要做过多的配置,直接在文件头部加入 // @ts-check的 comment 就可以开启 TypeScript checking.

WebStorm

对于 WebStorm,你需要提供对应的tsconfig.json文件来显式的打开这个特性。

{
  "compilerOptions": {
    "allowJs": "true", // 开启js checking
    "noEmit": "true", // 不生成输出文件
    "target": "es6", // 支持es6语法
    "checkJs": "true" // 打开会检查所有的js, 如果不需要可以手动的在需要检查的js文件头部加上 @ts-check的标记.
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.spec.ts"]
}

工作流集成

除开 IDE 和编辑器等开发工具的集成之外,我们还需要把 TypeScript Checking JavaScript 集成进我们的构建流程之中。TypeScript 的 CLI 提供了非常强大的功能,由于在tsconfig.json中声名了只做类型检查,不做实际的编译,所以通过一条简单的tsc命令就能很好的帮助我们在构建中检查我们的代码是否存在构建错误。

一些限制

凡事都有两面性,Type Checking JS 也并非没有缺点。在一段时间的试验后,我发现和直接编写 TypeScript 相比,Type Checking JS 存在以下不爽的地方

1. 使用 comments 破坏代码语义化

比起类型声明和代码结合在一起,使用 comments 的形式难免在阅读和编写的时候感觉有些别扭,在代码上也有一定的冗余。

// ts 写法
let foo:string = (bar: string) => `${bar}`

// type checking js写法
/** @type {(bar: string) => string}
let foo = bar => `${bar}`

2. 语法支持不统一

Webstorm 出于某种原因并不支持 typescript like 的语法,我们只能退而求其次选择 closure like 的语法。 这让 Type Checking JS 的写法下降了不止一个档次。

总结

虽然最后聊到了 Type Checking JS 的一些缺陷和限制,但是这并不妨碍其成为改造老旧项目或者向 TypeScript 过渡的比较不错的一个方案。最后也希望社区能继续推动和改进这种写法,让更多的老旧项目尽可能多的享受 TypeScript 带来的各种便利的特性。

参考

Type Checking JavaScript Files · TypeScript