认识 TypeScript
作者: 发布于:

这是一个系列文章,一共会有三篇:

  1. [本篇] 认识 TypeScript - 简单描述 TypeScript 的定位、特点。
  1. TypeScript 语法学习 - 比较文档化的讲述 TypeScript 的使用方式。
  1. TypeScript 工程使用 - 讲述如何在工程 —— 包括 Node.js 工程、React 工程 —— 中应用 TypeScript。

最近几个月有幸体验了 TypeScript,它对大型软件 —— 大型 JavaScript 软件 —— 开发来说真是一把利刃。就像我斜后方那个男人一样,写了 TypeScript 就不想再写 JavaScript 了 —— 真乃取其精华去其糟粕啊!

没想到我一个 Linux 的老用户 Copyleft 的忠实拥护者,也会像今天这样喜欢用 M$ 出的东西,17 岁到 19 岁的我一定会鄙视死现在的我。

背景认识

TypeScript 是微软开发一款开源的编程语言,本质上是向 JavaScript 增加静态类型系统。它是 JavaScript 的超集,所有现有的 JavaScript 都可以不加改变就在其中使用。它是为大型软件开发而设计的,它最终编译产生 JavaScript,所以可以运行在浏览器、Node.js 等等的运行时环境。

什么是类型系统

下面是摘自 《 Types And Programming Languages 》 中的定义:

A type system is a tractable syntactic method for proving the absence of certain program behaviors by classifying phrases according to the kinds of values they compute.

第一个重点是 Proving the absence of certain program behaviors,所以我们亦可将类型检查器看做一个程序推理工具,可以静态的证明程序成立。

另一个重点是 Classifying phrases according to the kinds of values they compute,对词语(比如变量)的值的性质进行分类,比如说 TypeScript 中的 InterfaceClass 等能力。

静态类型系统是什么

增加静态这个定语,是为了和运行时的类型检查机制加以区分,强调静态类型系统是在编译时进行类型分析。

JavaScript 不是一个静态编译语言,不存在编译这一步骤。但从 程序推理工具 的角度来看,JavaScript 的配套中还是有不少的,比如 ESLint 这个不完备的 程序推理工具

静态类型系统与 Lint 工具的关系

我们先看 ESLint 的定义:

Code linting is a type of static analysis that is frequently used to find problematic patterns or code that doesn’t adhere to certain style guidelines.

区别一

同样强调 Static Analysis,不过更强调 Certain Style GuidelinesLint 工具是一种团队协作时的风格规范工具。

区别二

静态类型类型分析Lint 工具 的区别在于 Lint 工具 没有 Classifying phrases according to the kinds of values they compute

Lint 工具无法基于类型对程序进行静态分析,但两者都有基于 CFG (控制流图,Control Flow Graph)对程序进行分析的能力。比如 TypeScript 的控制流分析、ESLintcomplexity (当你想写个比较复杂的迭代算法时,这个规则就是个渣) 规则等。

TypeScript 和 JavaScript 的关系

和一些基于 JavaScript 的激进语言不同(比如 CoffeeScript),TypeScript 的语法设计首先考虑的就是兼容 JavaScript,或者说对 JavaScript 的语法做扩展。基本上是在 JavaScript 的基础之上增加了一些类型标记语法,以实现静态类型分析。把这些类型标注语法去掉之后,仍是一个标准的 JavaScript 语言。

TypeScript 同样也在做一些新语法编译到老语法的事情(就像 Babel 做的), 基本实现常用的 EcmaScript Stage 1 以上的语法特性。

类型系统的益处

侦测错误

静态类型分析首要优点就是能尽早的发现逻辑错误,而不是上线之后才发现。比如我们在 JavaScript 中经常发生的问题,函数返回值含混。在开发过程中坚信一个函数返回字符串,但到了线上接受了真实数据却返回了 undefined。看似一个简单错误,却可能给公司造成数以万计的损失。

看个例子。

// 通过分数获取图标
function getRankIcon(score) {
  if (score >= 100) {
    return '';
  } else if (score >= 500) {
    return '';
  } else if (score >= 1500) {
    return '';
  }
}
const icon = getRankIcon(5);
const iconArray = icon.split();

脆弱的 JS 啊,执行一下,Ops!

> node taste.js
TypeError: Cannot read property 'split' of undefined

相同的逻辑我们用 tsc 编译一下(甚至不需要增加任何的类型标注)。直接静态分析出来程序有一个 undefined

> tsc --strictNullChecks taste.ts
x.ts(11,19): error TS2532: Object is possibly 'undefined'.

另一个重要的用处是作为维护工具(重构辅助工具),假如我们有一个很通用的函数,在工程里用的到处都是,有一天我们要在这个函数最前面增加一个参数。TypeScript 中你只需要改那个函数就好了,然后再执行静态类型分析,所有和这个函数参数不匹配的地方都会提示出来。但是,在 JavaScript 里,这个改动很有可能被忽略或者漏掉,打包也不会报错,然后发布后线上就挂了……

抽象

类型系统的另一个优点是强化规范编程,TypeScript 提供了简便的方式定义接口。这一点在大型软件开发时尤为重要,一个系统模块可以抽象的看做一个 TypeScript 定义的接口。

用带清晰接口的模块来结构化大型系统,这是一种更为抽象的设计形式。接口设计(讨论)与最终实现方式无关,对接口思考得越抽象越有利。

换句话说就是让设计脱离实现,最终体现出一种 IDL(接口定义语言,Interface Define Language),让程序设计回归本质。

看个例子。

interface Avatar {
  cdnUrl: string; // 用户头像在 CDN 上的地址
  filePath: string; // 用户头像在对象存储上的路径
  fileSize: number; // 文件大小
}
interface UserProfile {
  cuid?: string; // 用户识别 ID,可选
  avatar?: Avatar; // 用户形象,可选
  name: string; // 用户名,必选
  gender: string; // 用户性别,必选
  age: number; // 用户年龄,必选
}
interface UserModel {
  createUser(profile: UserProfile): string; // 创建用户
  getUser(cuid: string): UserProfile; // 根据 cuid 获取用户
  listFollowers(cuid: string): UserProfile[]; // 获取所有关注者
  followByCuid(cuid: string, who: string): string; // 关注某人
}

那我实现上述 Interface 也只需如下进行。

class UserModelImpl implements UserModel {
   createUser(profile: UserProfile): string {
     // do something
   }
   // 把 UserModel 定义的都实现
}

文档

读程序时类型标注也有用处,不止是说人在读的时候。基于类型定义 IDE 可以对我们进行很多辅助,比如找到一个函数所有的使用,编写代码时对参数进行提示等等。

更重要的是这种文档能力不像纯人工维护的注释一样,稍不留神就忘了更新注释,最后注释和程序不一致。

更强大的是,可以自动根据类型标注产生文档,甚至都不需要编写注释(详细的人类语言描述还是要写注释的)。

首先安装全局的 typedoc 命令。

> npm install -g typedoc

然后我们尝试对上面抽象的 Interface 产生文档。

> typedoc taste.ts --module commonjs --out doc

然后下面就是效果了。

编写第一个 TypeScript 程序

这一节会介绍如何开始体验 TypeScript,下一节开始会介绍一些有特点、有趣的例子。

前置准备

安装 TypeScript。

npm install -g typescript

初始化工作区。

mkdir learning-typescript
cd learning-typescript

新建第一个测试文件。

touch taste.ts

第一个例子

我们刚才已经新建了一个名为 taste.ts 的文件,对 TypeScript 的后缀名为 ts,那我们写点什么进去吧!

taste.ts

function say(text: string) {
  console.log(text);
}
say('hello!');

然后执行命令(tsc 是刚才 npm 装的 typescript 中带的)。

tsc taste.ts

然后我们得到一个编译后的文件 taste.js,内容如下。

function say(text) {
  console.log(text);
}
say('hello!');

可以看到,只是简单去除了 text 后面的类型标注,然后我们用 node 执行 taste.js

node taste.js
// hello!

完美执行,让我再改写东西看看?

taste.ts

function say(text: string) {
  console.log(text);
}
say(969);

然后再执行 tsc taste.ts,然后就类型检查就报错了。这就是 TypeScript 的主要功能 —— 静态类型检查。

> tsc taste.ts 
taste.ts(4,5): error TS2345: Argument of type '969' is not assignable to parameter of type 'string'.

有趣的例子 - 基于控制流的分析

看一个 JavaScript 的例子。

function getDefaultValue (key, emphasis) {
  let ret;
  if (key === 'name') {
    ret = 'GuangWong';
  } else if(key=== 'gender') {
    ret = 'Man';
  } else if (key === 'age') {
    ret = 23;
  } else {
     throw new Error('Unkown key ' + info.type);
  }
  if (emphasis) {
    ret = ret.toUpperCase();
  }
  return ret;
}

getDefaultValue('name'); // GuangWong
getDefaultValue('gender', true) // MAN
getDefaultValue('age', true) // Error: toUpperCase is not a function

这是一个简单的函数,第一个参数 key 用来获得一个默认值。第二参数 emphasis 为了某些场景下要大写强调,只需要传入 true 即可自动将结果转成大写。

但是我不小心将 age 的值写成了数字字面量,如果我调用 getDefaultValue('age', true) 就会在运行时报错。这个有可能是软件上线了之后才发生,直接导致业务不可用。

TypeScript 就能避免这类问题,我们只需要进行一个简单的标注。

function getDefaultValue (key, emphasis?) {
  let ret: string;
  if (key === 'name') {
    ret = 'GuangWong';
  } else if (key === 'gender') {
    ret = 'Man';
  } else if (key === 'age') {
    ret = 23;
  } else {
     throw new Error('Unkown key ' + key);
  }
  if (emphasis) {
    ret = ret.toUpperCase();
  }
  return ret;
}

getDefaultValue('name'); // GuangWong
getDefaultValue('gender', true) // MAN
getDefaultValue('age', true) // Error: toUpperCase is not a function

tsc 编译时,逻辑错误会自动报出来。妈妈再也不怕我的逻辑混乱了!

> tsc taste.ts
x.ts(8,5): error TS2322: Type '23' is not assignable to type 'string'.

有趣的例子 - Interface

JavaScript 的类型我们称为鸭子类型。

当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。

鸭子类型总是有点损的感觉,不如叫做面向接口编程。所以 JavaScript 就是一门面向接口编程的语言,TypeScript 中相对应的就是 Interface

接下来看个例子。

interface Profile {
  name: string;
  gender: 'man' | 'woman';
  age: number;
  height?: number;
}

function printProfile(profile: Profile) {
  console.log('name', profile.name);
  console.log('gender', profile. gender);
  console.log('age', profile.age);
  if (profile.height) {
    console.log('height', profile.height);
  }
}

printProfile({name: 'GuangWong', gender: 'man', age: 23});

使用 tsc 编译一切完美,那我们尝试下面的调用。

printProfile({name: 'GuangWong', age: 23});

使用 tsc 编译,报错了!说没有传属性 gender。不过 height 也没传怎么没报错呢?因为 height?: number,其中的 ? 表示这个是可选的。

> tsc taste.ts
x.ts(19,14): error TS2345: Argument of type '{ name: string; age: number; }' is not assignable to parameter of type 'Profile'.
  Property 'gender' is missing in type '{ name: string; age: number; }'.

接下来我们试着传个非 numberheight 试试看。

printProfile({height: '190cm', name: 'GuangWong', gender: 'man', age: 23});

使用 tsc 编译,报错了!string 类型无法赋值给 number 类型。

> tsc taste.ts
x.ts(17,14): error TS2345: Argument of type '{ height: string; name: string; gender: "man"; age: number; }' is not assignable to parameter of type 'Profile'.
  Types of property 'height' are incompatible.
    Type 'string' is not assignable to type 'number'.

有趣的例子 - Implements

这也是 Interface 的应用,假设我们有这么一个 Interface,是某个架构师写的让我来实现一种事物,比如榴莲。

type Fell = 'good' | 'bad';
interface Eatable {
  calorie: number;
  looks(): Fell;
  taste(): Fell;
  flavour(): Fell;
}

我只需要简单的实现  Eatable 即可,即 implements Eatable

class Durian implements Eatable {
  calorie = 1000;
  looks(): Fell {
    return 'good';
  }
  taste(): Fell {
    return 'good';
  }
  flavour(): Fell {
    return 'bad';
  }
}

如果我删掉 flavour 的实现,那就会报错了!说我错误的实现了 Eatable

> tsc taste.ts
x.ts(8,7): error TS2420: Class 'Durian' incorrectly implements interface 'Eatable'.
  Property 'flavour' is missing in type 'Durian'.

有趣的例子 - 函数重载

什么重载啊、多态啊、分派啊,在 JavaScript 里都是不存在的!那都是都是我们 Hacking 出来,Ugly!

TypeScript 对函数重载有一定的支持,不过因为 TypeScript 不扩展 JavaScript 的运行时机制,还是需要我们来处理根据宗量分派的问题(说白了就是运行时类型判断)。

下面是 TypeScript 文档中的一个例子。

let suits = ["hearts", "spades", "clubs", "diamonds"];

function pickCard(x: {suit: string; card: number; }[]): number;
function pickCard(x: number): {suit: string; card: number; };
function pickCard(x): any {
    // Check to see if we're working with an object/array
    // if so, they gave us the deck and we'll pick the card
    if (typeof x == "object") {
        let pickedCard = Math.floor(Math.random() * x.length);
        return pickedCard;
    }
    // Otherwise just let them pick the card
    else if (typeof x == "number") {
        let pickedSuit = Math.floor(x / 13);
        return { suit: suits[pickedSuit], card: x % 13 };
    }
}

let myDeck = [{ suit: "diamonds", card: 2 }, { suit: "spades", card: 10 }, { suit: "hearts", card: 4 }];
let pickedCard1 = myDeck[pickCard(myDeck)];
alert("card: " + pickedCard1.card + " of " + pickedCard1.suit);

let pickedCard2 = pickCard(15);
alert("card: " + pickedCard2.card + " of " + pickedCard2.suit);

这样至少在函数头的描述上清晰多了,而且函数的各个分派函数的类型定义也可以明确的标记出来了。

最后

第一篇就先这样简单介绍 TypeScript 吧,详细的语法、工程应用接下来的篇幅中再详细介绍。

  1. 如果大家对 TypeScript 真的有兴趣的话,可以移步官方文档继续学习。
    http://www.typescriptlang.org/docs/tutorial.html
  1. 如果大家对类型系统的理论比较感兴趣的,建议把 《 Types And Programming Languages 》 买了并看了。

题图:https://unsplash.com/photos/HbbHfXvb6Xw By Nirzar Pangarkar