为了方便书写,以下用ts来指代TypeScript。

ts已经用了一段时间了,为了更系统的学习ts,仔细品读了一遍官方文档,由此做了些记录和总结。希望这篇文章也能帮助到正在学习ts的你,带领你入门。

为什么要使用ts?

首先抛出我观点:并不是说使用ts就一定是好的,你应该根据你的业务需要决定。但我认为对于一个长期维护或者多人参与的项目,ts相比较于js,会是更好的选择。

从减少代码出错率、易读性和开发高效三个方面,我总结了以下原因,如果这些原因还说服不了你,你可以放弃ts了。

提前抛出错误

ts的类型检查机制让我们把本来一些在运行时才能发现的类型错误,提前到了在我们编码时编辑器就已经抛出错误了。

场景一 :避免错误的调用。 比如下面的代码,我们很容易直观的认为 result 是一个 Array 类型,但其实 [].push('string') 返回的是数组的长度,是 number 类型,所以在执行 result.join(',') 的时候会报错。

let result = [].push('string');
console.log(result.join(','));
//                 ~~~~类型“number”上不存在属性“join”。

场景二:避免基本逻辑错误。 乍一看下面这段代码没什么问题,但仔细分析,你会发现else if条件下的代码并不会执行,因为value既不能是'a'又是'b',所以这个条件永远不会成立。那么else if条件下的代码其实是一段冗余代码,虽然不会给我们程序带来什么影响,但如果真的不需要,删除它或许是更好的选择。

const value = Math.random() < 0.5 ? "a" : "b";
if (value !== "a") {
  // ...
} else if (value === "b") {
  //       ~~~~~~~~~~~~~ 此条件将始终返回 "false",因为类型 ""a"" 和 ""b"" 没有重叠 
  // 此条件里的代码不会执行

易于使用的函数

场景一:增强函数的可读性。看下面的代码,通过ts的类型注解,我们很容易就知道greet函数需要传入什么类型的值,也没有返回值,不需要我们再额外地写注释。

function greet(person: string, week: string): void {
  console.log(`Hello ${person}, today is ${week}!`);

场景二:控制函数的输入输出。当函数实际传入的参数个数少于或者多于函数需要传入的参数个数时,或者将函数返回值赋给其他类型的值时,编辑器都会抛出错误。比如我们像下面这样使用上面编写的函数:

greet("Brendan");
//~~~~~~~~~~~~~~应有 2 个参数,但获得 1 个。
greet("Brendan", 'Sunday', 'test');
//                          ~~~~~应有 2 个参数,但获得 3 个。
let result: string = '';
result = greet("Brendan", '');
//~~~~不能将类型“void”分配给类型“string”。

更高效开发

使用过ts的人都知道,ts写类型注解那么麻烦,从这一点看就不可能说开发更高效呢?这取决于你如何理解高效率开发,而并不是说你每天很忙或者完成工作很快就是所谓的高效率。

场景一:减少阅读成本。如果我们不约束一个变量的类型,那它在任何时候都有可能会变成其他类型,那我一定要阅读完关于它所有的代码我才会知道它现在是类型。这样的话,当我们维护一个项目的时候,实际上我们要花费很多时间去阅读之前的代码。假如在js中有下面这段代码:

let result = ''
if(...){
  result = Number(result)
}else if(...){
  //...
  result = str.split('')
  //...
}else{
  //...
  result = arr.join(',')

result最开始是string类型,当它满足某些条件的时候有可能变成number或者Array类型了,那我在使用string或者Array上的方法的时候就必须得非常谨慎了。如果在ts中去约束了该变量的类型,那么它只会是这种类型,就没有这种担忧了。

据广泛估计,开发人员将70%的代码维护时间花在阅读上以理解它。这真让人大开眼界。居然达到了70%。难怪程序员每天编写的代码行数的平均值大约是10行。我们每天花7个小时来阅读代码,去理解这10行怎么运行!

场景二:更友好的编辑器提示。比如下面这段代码:

interface IPerson {
  name: string,
  age: number,
  gender: '男'|'女'
const user1: IPerson = {
  name: 'Tom',
  age: 30,
  gender: '男'
const user2: IPerson = {
  name: 'Army',
  age: 24,
  gender: '女'

我们定义一个实体接口IPerson,对象user1user2都是IPerson类型,我们去设置变量的时候编辑器会根据IPerson接口定义的属性给出属性提示:

gender定义为'男'|'女'字面量类型,会有以下提示:

string、number、boolean

stringnumberboolean是最常用的三种类型,它们的类型注解和你使用typeof获取到的字符串内容是一样的,理解起来也是比较容易。

const str: string = 'Hello World';
const num: number = 23;
const isTrue: boolean = false;

数组Array

数组类型和数组内元素的类型有关,比如全是数字的数组,有两种表示方式:

const nums: number[] = [1, 2, 3];
const nums: Array<number> = [1, 2, 3];

数组内元素是其他类型的时候也是类似写法。

我们上面说过数组里的每项元素类型都是一样的,那当元素的类型不一样,并且每个位置的元素类型是固定的,这种我们称为元组类型。

// 给元组赋初始化值需要提供所有元组类型中指定的项
let tom: [string, number] = ['Tom', 26];
// 也可以只给元组某一项赋值,但要赋值指定的类型
let tom: [string, number];
tom[0] = 'Tom'; //ok
tom[0] = 12; //error
//~~~~不能将类型“number”分配给类型“string”
// 也可以用?表示可选
let tom: [string, number, string?] = ['Tom', 26];
tom[2] = 'male'

我们可以用push往元组里面添加新元素,并且只能添加元组内元素类型的联合类型,但是ts不允许我们访问越界元素

let tom: [string, number] = ['Tom', 26];
tom.push(true); 
//       ~~~~~类型“boolean”的参数不能赋给类型“string | number”的参数
tom.push('male'); // ok
console.log(tom); // ['Tom', 26, 'male']
tom[2];
//  ~~长度为 "2" 的元组类型 "[string, number]" 在索引 "2" 处没有元素

any表示值可以是任意类型,下面这些表达式都是合法的。

let obj: any = { x: 0 };
obj.foo();
obj();
obj.bar = 100;
obj = "hello";
const n: number = obj;

当你不想进行类型检查的时候,可以使用any,但这也就失去了类型检查的意义了,建议项目中尽可能避免使用any

对象的类型注解,我们一般是列举出对象的所有属性,并指定每个属性的类型。

const person:{ name: string; age?: number} = { name: '张三三' };

对象的类型注解中每个属性间可以用;或者,分开。对象的每个属性都是可选的,在可选属性名后面加?表示,比如上面代码中personage属性是可选的,即person没有age属性也是合法的。

在js中如果你使用了对象上一个不存在的属性,那么这个属性值会被认为是undefined,而不是发生运行错误。所以当你想要使用对象中一个可选属性的时候,你一定要检查该属性是否是undefined

function printName(obj: { first: string, last?: string }) {
  // 如果'obj.last'的值没有提供的话,obj.last值为undefined,会造成错误
  console.log(obj.last.toUpperCase());
  // 所以先要判断是否为undefined.
  if (obj.last !== undefined) {
    // OK
    console.log(obj.last.toUpperCase());
  // js中可以采用链式的写法去判断,这样写也是OK的
  console.log(obj.last?.toUpperCase());

ts的类型机制允许我们根据一些存在的类型去创造一些新的类型,联合类型就是这样的一种类型,它是由两种或者多种类型结合起来,这些类型称为联合成员

比如说我们一个函数的参数它可能是number类型,也可能是string类型,传入这两种类型的时候都没问题,传入其他类型就会报错:

function printId(id: number | string) {
  console.log("Your ID is: " + id);
// OK
printId(101);
// OK
printId("202");
// Error
printId({ myID: 22342 });
//      ~~~~~~~~~~~~~~类型“{ myID: number; }”的参数不能赋给类型“string | number”的参数。
//                     不能将类型“{ myID: number; }”分配给类型“number”。

当你为变量声明联合类型的时候,ts会去检查是否对每个联合成员生效。比如:

function printId(id: number | string) {
  console.log(id.toUpperCase());  
  //             ~~~~~~~~~~~类型“string | number”上不存在属性“toUpperCase”。
  //                         类型“number”上不存在属性“toUpperCase”。

解决方案是用typeof改善你的代码,做条件判断,id为不同类型时执行各自的操作

function printId(id: number | string) {
  if (typeof id === "string") {
    // id是'string'类型
    console.log(id.toUpperCase());
  } else {
    // id是'number'类型
    console.log(id);

上面我们说到的对象类型和联合类型,是直接赋给变量的,但是如果有多处地方用到同一种类型,我们可以给类型取个别名,在用到的地方,用它的别名就行了,不用每个地方都去写一次。

给对象类型取别名:

type Point = {
  x: number;  
  y: number;  
function printCoord(pt: Point) {  
  console.log("The coordinate's x value is " + pt.x);  
  console.log("The coordinate's y value is " + pt.y);  
const point: Point = { x: 100, y: 100 };
printCoord(point);

给联合类型取别名:

type ID = number | string;

你可以给任何类型取别名,不过别名仅仅是这种类型的另外一个叫法,实质上它们是同一种类型。

另一种方法命名对象类型就是接口,就像上面说到的type别名有类似作用。比如上面的例子,我们同样可以把Pointtype改成interface声明,作用是一样的:

interface Point {
  x: number;  
  y: number;  
function printCoord(pt: Point) {  
  console.log("The coordinate's x value is " + pt.x);  
  console.log("The coordinate's y value is " + pt.y);  
printCoord({ x: 100, y: 100 });

interface和type的区别:

从上面的例子我们可以看出interfacetype很相似,都可以描述一个对象或者函数。大多数时候你可以根据自己喜好选择使用type还是interface,关键的区别有下面两点:

  • 扩展性interface使用extends关键字来扩展属性;type通过&来拓展属性。
  • // interface
    interface Animal {
      name: string
    interface Bear extends Animal {
      honey: boolean
    const bear = getBear() 
    bear.name
    bear.honey
    // type
    type Animal = {
      name: string
    type Bear = Animal & { 
      honey: boolean 
    const bear = getBear();
    bear.name;
    bear.honey;
    
  • 合并声明interface可以重复声明,新的会和旧的合并;type不能重复声明,会报错
  • // interface
    interface Window {
      title: string
    interface Window {
      ts: TypeScriptAPI
    const src = 'const a = "Hello World"';
    window.ts.transpileModule(src, {});
    // type
    type Window = {
      title: string
    type Window = {
      ts: TypeScriptAPI
     // Error: 标识符“Window”重复.
    

    此外,以下几点type可以做到,而interface不可以:

  • 可以重命名原始类型、联合类型等类型
  • // 基本类型别名 
    type Name = string
    // 联合类型
    type ID = string | number
    // 元组
    type List = [string, number]
    
  • 使用typeof获取实例类型
  • const div = document.createElement('div');
    type DivElement = typeof div;
    

    总结:一般来说interface经常用来描述“数据结构”,type用来对类型取别名。

    字面量类型

    除了stringnumber类型,我们还可以定义指定内容为字符串或者数字。

    首先,我们看下js中声明变量的规则,varlet声明的变量是可被重新赋值的,用const声明的变量不能被重新赋值,这会影响ts创建字面类型。

    let changingStr = "Hello World";
    changingStr = "Olá Mundo";
    // `changingStr`可被重新赋值为任何字符串,所以是string类型
    const constantStr = "Hello World";
    // `constantStr`不能被重新赋值,值为"Hello World",所以ts会推断其为"Hello World"类型
    

    这看起来没什么用,但是结合联合类型,你会发现它的用处,我们看看下面这些例子:

    例子1:限制函数参数。

    function printText(s: string, alignment: "left" | "right" | "center") {
      // ...
    printText("Hello, world", "left");
    printText("day, mate", "centre");
    //                      ~~~~~~~类型“"centre"”的参数不能赋给类型“"left" | "right" | "center"”的参数。
    

    例子2:限制函数返回结果。

    function compare(a: string, b: string): -1 | 0 | 1 {
      return a === b ? 0 : a > b ? 1 : -1;
    

    例子3:结合其他非字面类型使用。

    interface Options {
      width: number;
    function configure(x: Options | "auto") {
      // ...
    configure({ width: 100 });
    configure("auto");
    configure("automatic");
    //        ~~~~~~~~~~~类型“"automatic"”的参数不能赋给类型“Options | "auto"”的参数
    

    据此,你可以把boolean类型理解为是true | false字面量类型的一个别名。

    null 和 undefined

    ts中有个配置项是strictNullChecks,如果关闭了strictNullChecks,那么nullundefined被认为可以是任何类型的属性,也就失去了检查nullundefined类型的作用,所以一般我们是不建议关闭这个配置项的。

    strictNullChecks开启的状态下,ts会把nullundefined当作单独的类型进行类型检查,像下面这种情况就必须把xnull时候单独做处理。

    function doSomething(x: string | null) {
      if (x === null) {
        // do nothing
      } else {
        console.log("Hello, " + x.toUpperCase());
    

    ts中还有个特殊符号!会从类型中移除对nullundefined的类型检查,称为非空断言。如下代码,断言x不可能是null,所以ts会认为这是没有问题的。

    function liveDangerously(x?: number | null) {
      // No error
      console.log(x!.toFixed());
    

    注意:像其他类型断言一样,非空断言在编译时被移除,所以当你使用的时候一定要确保值不可能是null或者undefined

    枚举其实是ts在js类型上做的一个扩展类型,和其他的类型或者断言不一样,在运行态时枚举是真实存在的对象。

    我们经常会遇到一个状态有多种值的情况,我们传给后端是number类型,但在代码中0代表什么状态没有那么直观,从语义化角度来看,使用枚举Status.New更容易理解。下面是一个数字枚举,New给定了初始值,New后面的成员会自动增长1,如果成员都不给定初始值,第一个成员的值默认是0,后面的成员自动增长1。当然你可以给每个成员都指定值。

    enum Status {
      'New' = 0,
      'Success',
      'Disabled',
      'Delete',
    

    字符串枚举

    在一个字符串枚举里,每个成员都必须用字符串字面量,或另外一个字符串枚举成员进行初始化。如果你正在调试并且必须要读一个数字枚举的运行时的值,这个值通常是很难读的 - 它并不能表达有用的信息,字符串枚举允许你提供一个运行时有意义的并且可读的值,独立于枚举成员的名字。

    // 驼峰的写法相比于全大写更易读
    enum Direction { Up = "UP", Down = "DOWN", Left = "LEFT", Right = "RIGHT", }
    

    为了避免在额外生成的代码上的开销和额外的非直接对枚举成员的访问,可以使用常量枚举,用const来定义。常量枚举在编译阶段会被删除,如下

    const enum Directions { 
        Up, Down, Left, Right 
    let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right]
    //编译后生成
    var directions = [0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */];
    

    泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

    泛型是ts中应用比较多的一种类型,因为我们知道在项目中,数据的类型往往比较复杂,要考虑可重用性,所以我们需要有更灵活功能的泛型。

    从下面的代码我们可以看出,参数value可以是任何类型,并且createArray函数的返回结果一定是数组,并且数组的元素类型和value的类型是一样的。这里泛型的作用是帮助我们控制函数返回结果的类型是value类型的数组,建立起函数的通用性。

    function createArray<T>(length: number, value: T): Array<T> {
        let result: T[] = [];
        for (let i = 0; i < length; i++) {
            result[i] = value;
        return result;
    createArray(3, 'x'); // ['x', 'x', 'x']
    createArray(2, { name: 'test' });
    

    我们还经常会遇到请求分页接口的时候,请求参数有类似的地方,这个时候也可以用泛型去定义我们请求参数的类型:

    interface IQuery<T> {
      page: number
      size: number
      data?: T
    const queryInfo: IQuery<{ name: string }> = {
      page: 1,
      size: 20,
      data: { name: 'test' }
    const queryInfo1: IQuery<{ type: number }> = {
      page: 1,
      size: 20,
      data: { type: 1 }
    

    当然,泛型的用法不止于此,后面会写一篇文章单独讲解泛型。

    BigInt 和 Symbol

    js中还有另外两种后面新增的数据类型BigIntSymbol,在ts中BigInt类型对应的类型注解是bigint,ts会推断Symbol类型对应的是唯一的类型,比如下面代码中,firstName的类型是'typeof firstName'

    // bigint
    const oneHundred: bigint = BigInt(100);
    // Symbol
    const firstName = Symbol("name");
    const secondName = Symbol("name");
    if (firstName === secondName) {
      //~~~~~~~~~~~~~~~~~~~~~~~~此条件将始终返回 "false",因为类型 "typeof firstName" 
      //                        和 "typeof secondName" 没有重叠
      // Can't ever happen
    

    当你用constvar或者let声明变量时你可以直接声明类型,但大多数时候我们也可以不用声明类型,因为ts的类型推断机制会根据你给定的初始值推断出类型。

    当ts无法推断出类型的时候,默认是any类型。我们可以通过配置ts的编译选项设置项目中表达式和声明上有隐含的any类型时报错,在tsconfig.json文件中配置:

    // 编译选项 "compilerOptions": { "noImplicitAny": false, //... //...

    匿名函数和具名函数的表现形式不一样,我们可以看下面的例子:

    // names没有显示指明类型
    const names = ["Alice", "Bob", "Eve"];
    // forEach里面是个匿名函数
    names.forEach((s) => {
      console.log(s.toUppercase());
      //           ~~~~~~~~~~~~属性“toUppercase”在类型“string”上不存在。你是否指的是“toUpperCase”?
    

    这里的s没有显示声明类型,但是因为ts会自动推断names的数据类型为string[],那么自然s的类型就被推断为string类型,所以ts会检查string类型上有没有toUppercase方法。

    字面量类型推断

    当你声明并初始化一个对象,ts会默认对象的所有属性是可以被改变的,根据上面我们讲的,下面例子中req.method会推断为string类型,而不是"GET"类型,所以在调用的handleRequest方法的时候,ts会提示错误。

    function handleRequest(url:string, method: "GET" | "POST"){
      //...
    const req = { url: "https://example.com", method: "GET" };
    handleRequest(req.url, req.method);
    //                     ~~~~~~~~~~~类型“string”的参数不能赋给类型“"GET" | "POST"”的参数
    

    解决方法

  • 1.使用类型断言把类型声明为“GET”类型,下面两种方式都可以
  • const req = { url: "https://example.com", method: "GET" as "GET" };
    // 或者
    handleRequest(req.url, req.method as "GET");
    
  • 2.使用as const把整个对象转变为字面量类型
  • const req = { url: "https://example.com", method: "GET" } as const;
    handleRequest(req.url, req.method);
    

    当ts无法分辨出类型,但你明确知道这是什么类型的时候,可以用类型断言告诉ts变量的类型。类型断言有两种方式,一种是as关键字,一种是<>形式(在.tsx文件中不能用这种形式),两者是等效的。

    比如当你根据元素id,用document.getElementById去获取cavas元素的时候,ts只知道这是一种HTMLElement,但你知道这是HTMLCanvasElement,你可以用断言告诉ts:

    const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
    // 或者
    const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");
    

    断言的用途:以下几种情况用的比较多

  • 将一个联合类型断言为其中一个类型
  • function upperCaseID(id: string|number){
      return (id as string).toUpperCase()
    
  • 将一个父类断言为更加具体的子类
  • interface ApiError extends Error {
      code: number;
    interface HttpError extends Error {
      statusCode: number;
    function isApiError(error: Error) {
      if (typeof (error as ApiError).code === 'number') {
        return true;
      return false;
    
  • 将任何一个类型断言为 any
  • (window as any).name = 'Tom';
    
  • 将 any 断言为一个具体的类型
  • const temp: any = 'Hello World';
    console.log((temp as string).toLowerCase());
    

    要使得A能够被断言为B,只需要A兼容BB兼容A即可,这也是为了在类型断言时的安全考虑,毕竟毫无根据的断言是非常危险的。其他不可能实现的情况会被阻止,比如:

    const x = "hello" as number;
    //        ~~~~~~~~~~~~~~~~~类型 "string" 到类型 "number" 的转换可能是错误的,因为两种类型
    //                         不能充分重叠。如果这是有意的,请先将表达式转换为 "unknown"。
    

    如果你觉得这规定太死,非要进行类型转换(不推荐),用两次断言可以达到这样的目的,先断言为any或者unknown类型,再断言为number,如:

    const x = ("hello" as any) as number;
    

    everyday-types