TypeScript手记(六)

2020-08-26 09:51:09 浏览数 (1)

类型推断

这节介绍 TypeScript 里的类型推断。即,类型是在哪里如何被推断的。

基础

TypeScript 里,在有些没有明确指出类型的地方,类型推断会帮助提供类型。如下面的例子:

代码语言:javascript复制
let x = 3

变量 x 的类型被推断为数字。这种推断发生在初始化变量和成员,设置默认参数值和决定函数返回值时。

大多数情况下,类型推断是直截了当地。后面的小节,我们会浏览类型推断时的细微差别。

最佳通用类型

有些时候我们需要从几个表达式中推断类型,会使用这些表达式的类型来推断出一个最合适的通用类型。例如,

代码语言:javascript复制
let x = [0, 1, null]

为了推断 x 的类型,我们必须考虑所有元素的类型。这里有两种选择:number 和 null。计算通用类型算法会考虑所有的候选类型,并给出一个兼容所有候选类型的类型。

由于最终的通用类型取自候选类型,有些时候候选类型共享一个公共结构,但是却没有一个类型能做为所有候选类型的超级类型。例如:

代码语言:javascript复制
class Animal {
  numLegs: number
}

class Bee extends Animal {
}

class Lion extends Animal {
}

let zoo = [new Bee(), new Lion()]

这里,我们想让 zoo 被推断为 Animal[] 类型,但是这个数组里没有对象是 Animal 类型的,因此不能推断出这个结果。为了更正,我们可以明确的声明我们期望的类型:

代码语言:javascript复制
let zoo: Animal[] = [new Bee(), new Lion()]

如果没有找到最佳通用类型的话,类型推断的结果为联合数组类型,(Bee | Lion)[]

上下文类型

有些时候,TypeScript 类型推断会按另外一种方式,我们称作“上下文类型”;上下文类型的出现和表达式的类型以及所处的位置相关。比如:

代码语言:javascript复制
window.onmousedown = function(mouseEvent) {
  console.log(mouseEvent.clickTime)  // Error
}

这个例子会得到一个类型错误,TypeScript 类型检查器使用 window.onmousedown 函数的类型来推断右边函数表达式的类型。因此,就能推断出 mouseEvent 参数的类型了,所以 mouseEvent 访问了一个不存在的属性,就报错了。

如果上下文类型表达式包含了明确的类型信息,上下文的类型被忽略。重写上面的例子:

代码语言:javascript复制
window.onmousedown = function(mouseEvent:any) {
  console.log(mouseEvent.clickTime)  // OK
}

这个函数表达式有明确的参数类型注解,上下文类型被忽略。这样的话就不报错了,因为这里不会使用到上下文类型。

上下文类型会在很多情况下使用到。通常包含函数的参数,赋值表达式的右边,类型断言,对象成员,数组字面量和返回值语句。上下文类型也会做为最佳通用类型的候选类型。比如:

代码语言:javascript复制
function createZoo(): Animal[] {
  return [new Bee(), new Lion()]
}

let zoo = createZoo()

这个例子里,最佳通用类型有 3 个候选者:Animal,Bee 和 Lion。其中,Animal 会被做为最佳通用类型。

高级类型

交叉类型

交叉类型是将多个类型合并为一个类型。这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。例如,Person & Loggable 同时是 Person 和 Loggable。就是说这个类型的对象同时拥有了这两种类型的成员。

我们大多是在混入(mixins)或其它不适合典型面向对象模型的地方看到交叉类型的使用。(在 JavaScript 里发生这种情况的场合很多!) 下面是如何创建混入的一个简单例子:

代码语言:javascript复制
function extend<T, U> (first: T, second: U): T & U {
  let result = {} as T & U
  for (let id in first) {
    result[id] = first[id] as any
  }
  for (let id in second) {
    if (!result.hasOwnProperty(id)) {
      result[id] = second[id] as any
    }
  }
  return result
}

class Person {
  constructor (public name: string) {
  }
}

interface Loggable {
  log (): void
}

class ConsoleLogger implements Loggable {
  log () {
    // ...
  }
}

var jim = extend(new Person('Jim'), new ConsoleLogger())
var n = jim.name
jim.log()

联合类型

联合类型与交叉类型很有关联,但是使用上却完全不同。偶尔你会遇到这种情况,一个代码库希望传入 number 或 string 类型的参数。例如下面的函数:

代码语言:javascript复制
function padLeft(value: string, padding: any) {
  if (typeof padding === 'number') {
    return Array(padding   1).join(' ')   value
  }
  if (typeof padding === 'string') {
    return padding   value
  }
  throw new Error(`Expected string or number, got '${padding}'.`)
}

padLeft('Hello world', 4) // returns "    Hello world"

padLeft 存在一个问题,padding 参数的类型指定成了 any。这就是说我们可以传入一个既不是 number 也不是 string 类型的参数,但是 TypeScript 却不报错。

代码语言:javascript复制
let indentedString = padLeft('Hello world', true) // 编译阶段通过,运行时报错

为了解决这个问题,我们可以使用 联合类型做为 padding 的参数:

代码语言:javascript复制
function padLeft(value: string, padding: string | number) {
  // ...
}

let indentedString = padLeft('Hello world', true) // 编译阶段报错

联合类型表示一个值可以是几种类型之一。我们用竖线(|)分隔每个类型,所以 number | string 表示一个值可以是 number 或 string。

如果一个值是联合类型,我们只能访问此联合类型的所有类型里共有的成员。

代码语言:javascript复制
interface Bird {
  fly()
  layEggs()
}

interface Fish {
  swim()
  layEggs()
}

function getSmallPet(): Fish | Bird {
  // ...
}

let pet = getSmallPet()
pet.layEggs() // okay
pet.swim()    // error

这里的联合类型可能有点复杂:如果一个值的类型是 A | B,我们能够确定的是它包含了 A 和 B 中共有的成员。这个例子里,Fish 具有一个 swim 方法,我们不能确定一个 Bird | Fish 类型的变量是否有 swim方法。如果变量在运行时是 Bird 类型,那么调用 pet.swim() 就出错了。

类型保护

联合类型适合于那些值可以为不同类型的情况。但当我们想确切地了解是否为 Fish 或者是 Bird 时怎么办?JavaScript 里常用来区分这 2 个可能值的方法是检查成员是否存在。如之前提及的,我们只能访问联合类型中共同拥有的成员。

代码语言:javascript复制
let pet = getSmallPet()

// 每一个成员访问都会报错
if (pet.swim) {
  pet.swim()
} else if (pet.fly) {
  pet.fly()
}

为了让这段代码工作,我们要使用类型断言:

代码语言:javascript复制
let pet = getSmallPet()

if ((pet as Fish).swim) {
  (pet as Fish).swim()
} else {
  (pet as Bird).fly()
}

用户自定义的类型保护

这里可以注意到我们不得不多次使用类型断言。如果我们一旦检查过类型,就能在之后的每个分支里清楚地知道 pet 的类型的话就好了。

TypeScript 里的类型保护机制让它成为了现实。类型保护就是一些表达式,它们会在运行时检查以确保在某个作用域里的类型。定义一个类型保护,我们只要简单地定义一个函数,它的返回值是一个类型谓词:

代码语言:javascript复制
function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined
}

在这个例子里,pet is Fish 就是类型谓词。谓词为 parameterName is Type 这种形式, parameterName 必须是来自于当前函数签名里的一个参数名。

每当使用一些变量调用 isFish 时,TypeScript 会将变量缩减为那个具体的类型。

代码语言:javascript复制
if (isFish(pet)) {
  pet.swim()
}
else {
  pet.fly()
}

注意 TypeScript 不仅知道在 if 分支里 pet 是 Fish 类型;它还清楚在 else 分支里,一定不是 Fish类型而是 Bird 类型。

typeof 类型保护

现在我们回过头来看看怎么使用联合类型书写 padLeft 代码。我们可以像下面这样利用类型断言来写:

代码语言:javascript复制
function isNumber (x: any):x is string {
  return typeof x === 'number'
}

function isString (x: any): x is string {
  return typeof x === 'string'
}

function padLeft (value: string, padding: string | number) {
  if (isNumber(padding)) {
    return Array(padding   1).join(' ')   value
  }
  if (isString(padding)) {
    return padding   value
  }
  throw new Error(`Expected string or number, got '${padding}'.`)
}

然而,你必须要定义一个函数来判断类型是否是原始类型,但这并不必要。其实我们不必将 typeof x === 'number'抽象成一个函数,因为 TypeScript 可以将它识别为一个类型保护。也就是说我们可以直接在代码里检查类型了。

代码语言:javascript复制
function padLeft (value: string, padding: string | number) {
  if (typeof padding === 'number') {
    return Array(padding   1).join(' ')   value
  }
  if (typeof padding === 'string') {
    return padding   value
  }
  throw new Error(`Expected string or number, got '${padding}'.`)
}

这些 typeof 类型保护只有两种形式能被识别:typeof v === "typename" 和 typeof v !== "typename", "typename"必须是 "number", "string","boolean" 或 "symbol"。但是 TypeScript 并不会阻止你与其它字符串比较,只是 TypeScript 不会把那些表达式识别为类型保护。

instanceof 类型保护

如果你已经阅读了 typeof 类型保护并且对 JavaScript 里的 instanceof 操作符熟悉的话,你可能已经猜到了这节要讲的内容。

instanceof 类型保护是通过构造函数来细化类型的一种方式。我们把之前的例子做一个小小的改造:

代码语言:javascript复制
class Bird {
  fly () {
    console.log('bird fly')
  }

  layEggs () {
    console.log('bird lay eggs')
  }
}

class Fish {
  swim () {
    console.log('fish swim')
  }

  layEggs () {
    console.log('fish lay eggs')
  }
}

function getRandomPet () {
  return Math.random() > 0.5 ? new Bird() : new Fish()
}

let pet = getRandomPet()

if (pet instanceof Bird) {
  pet.fly()
}
if (pet instanceof Fish) {
  pet.swim()
}

可以为 null 的类型

TypeScript 具有两种特殊的类型,null 和 undefined,它们分别具有值 null 和 undefined。我们在基础类型一节里已经做过简要说明。默认情况下,类型检查器认为 null 与 undefined 可以赋值给任何类型。null 与 undefined 是所有其它类型的一个有效值。这也意味着,你阻止不了将它们赋值给其它类型,就算是你想要阻止这种情况也不行。null的发明者,Tony Hoare,称它为价值亿万美金的错误。

--strictNullChecks 标记可以解决此错误:当你声明一个变量时,它不会自动地包含 null 或 undefined。你可以使用联合类型明确的包含它们:

代码语言:javascript复制
let s = 'foo'
s = null // 错误, 'null'不能赋值给'string'
let sn: string | null = 'bar'
sn = null // 可以

sn = undefined // error, 'undefined'不能赋值给'string | null'

注意,按照 JavaScript 的语义,TypeScript 会把 null 和 undefined 区别对待。string | null,string | undefined 和 string | undefined | null 是不同的类型。

可选参数和可选属性

使用了 --strictNullChecks,可选参数会被自动地加上 | undefined:

代码语言:javascript复制
function f(x: number, y?: number) {
  return x   (y || 0)
}
f(1, 2)
f(1)
f(1, undefined)
f(1, null) // error, 'null' 不能赋值给 'number | undefined'

可选属性也会有同样的处理:

代码语言:javascript复制
class C {
  a: number
  b?: number
}
let c = new C()
c.a = 12
c.a = undefined // error, 'undefined' 不能赋值给 'number'
c.b = 13
c.b = undefined // ok
c.b = null // error, 'null' 不能赋值给 'number | undefined'

类型保护和类型断言

由于可以为 null 的类型能和其它类型定义为联合类型,那么你需要使用类型保护来去除 null。幸运地是这与在 JavaScript 里写的代码一致:

代码语言:javascript复制
function f(sn: string | null): string {
  if (sn === null) {
    return 'default'
  } else {
    return sn
  }
}

这里很明显地去除了 null,你也可以使用短路运算符:

代码语言:javascript复制
function f(sn: string | null): string {
  return sn || 'default'
}

如果编译器不能够去除 null 或 undefined,你可以使用类型断言手动去除。语法是添加 ! 后缀:identifier! 从 identifier 的类型里去除了 null 和 undefined:

代码语言:javascript复制
function broken(name: string | null): string {
  function postfix(epithet: string) {
    return name.charAt(0)   '.  the '   epithet // error, 'name' 可能为 null
  }
  name = name || 'Bob'
  return postfix('great')
}

function fixed(name: string | null): string {
  function postfix(epithet: string) {
    return name!.charAt(0)   '.  the '   epithet // ok
  }
  name = name || 'Bob'
  return postfix('great')
}

broken(null)

本例使用了嵌套函数,因为编译器无法去除嵌套函数的 null(除非是立即调用的函数表达式)。因为它无法跟踪所有对嵌套函数的调用,尤其是你将内层函数做为外层函数的返回值。如果无法知道函数在哪里被调用,就无法知道调用时 name 的类型。

字符串字面量类型

字符串字面量类型允许你指定字符串必须具有的确切值。在实际应用中,字符串字面量类型可以与联合类型,类型保护很好的配合。通过结合使用这些特性,你可以实现类似枚举类型的字符串。

代码语言:javascript复制
type Easing = 'ease-in' | 'ease-out' | 'ease-in-out'

class UIElement {
  animate (dx: number, dy: number, easing: Easing) {
    if (easing === 'ease-in') {
      // ...
    } else if (easing === 'ease-out') {
    } else if (easing === 'ease-in-out') {
    } else {
      // error! 不能传入 null 或者 undefined.
    }
  }
}

let button = new UIElement()
button.animate(0, 0, 'ease-in')
button.animate(0, 0, 'uneasy') // error

你只能从三种允许的字符中选择其一来做为参数传递,传入其它值则会产生错误。

代码语言:javascript复制
Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

总结

那么到这里,我们的 TypeScript 常用语法学习就告一段落了,当然 TypeScript 还有其他的语法我们并没有讲 。如果你在使用 TypeScript 开发项目中遇到了其他的 TypeScript 语法知识,你可以通过 TypeScript 的官网文档学习。因为学基础最好的方法还是去阅读它的官网文档,敲上面的小例子。其实我们课程的基础知识结构也是大部分参考了官网文档,要记住学习一门技术的基础官网文档永远是最好的第一手资料。

但是 TypeScript 的学习不能仅仅靠看官网文档,你还需要动手实践,在实践中你才能真正掌握 TypeScript。相信很多同学学习到这里已经迫不及待想要大展身手了,那么下面我们就开始把理论转换为实践,一起来用 TypeScript 重构 axios 吧!

0 人点赞