浅谈 Typescript(三):两个空间的交流

浅谈 Typescript(三):两个空间的交流

是骡子是马,拉出来遛遛。
长久以来,JS 依靠着运行时把变量拉出来,小心翼翼判断类型,生怕调出 Uncaught TypeError: Cannot read property 'foo' of undefined或者Uncaught TypeError: foo.slice is not a function,然后白屏了。TS 出现后,终于有人在开发和编译阶段就管管 JS 变量的类型。

上一篇我们了解了 TS 在「类型声明空间」的行为,那「类型声明空间」的产物是如何约束「变量声明空间」的,「变量声明空间」又能为「类型声明空间」提供哪些信息呢?这就是本篇要讨论的——两个空间的交流。

本文你将看到:

  • 「类型声明空间」如何为「变量声明空间」的声明提供类型注解
  • 哪些场景下TS会“自动注解”?推断的规则又是怎样的?
  • 如果变量已有或推断的类型不准确,我们可以修正吗?又可能存在什么问题?
  • TS 怎样通过 JS 逻辑语句自动缩小类型范围?
  • 我们如何从「变量声明空间」提取类型声明?

1 类型注解

从「类型声明空间」到「变量声明空间」,最基础的,我们可以通过类型注解,为变量提供类型约束。如下图所示,我们上一篇在「类型声明空间」构造出的任何产物,都可以直接拿来注解。

什么叫约束呢?就像我有只宠物叫 Tony,我注解它是只狗,当你调用Tony.fly(),在编译时甚至写代码时就会报错,避免了等你执行的时候把狗抛出去才发现它没有 fly 方法。

基本注解

一个冒号是最基本的注解方式,它完成了从类型声明空间向变量声明空间的映射。

// 直接注解原始类型
const foo: number = 1;
// 注解已声明类型
type bar = number;
const foo: bar = 1;
// 用类型表达式注解
const baz: number | string = 1;
// 用接口表达式注解
const obj: {
    foo: number;
    bar: string;
} = { foo: 1, bar: 'string' };

函数注解

先声明类型再注解

上一篇我们说到两种声明函数类型的方式:

// 声明调用模式
interface Foo {     // 或者 type Foo =
  (bar: string): number;
};
// 声明函数类型
type Foo = (bar: string) => number;

然后,以字面量声明的函数变量就可以直接注解,这很符合前面冒号注解的风格。

const getLen: Foo = (input) => input.length;

但这样有个问题,就是你在函数声明这里,没法直观的看到输入输出的类型,可读性就差了那么一点点。

直接注解到函数声明上

所以更好的办法是直接在声明处注解。比如对于上述字面量声明,也可以直接注解上去:

const getLen = (input: string): number => input.length;

这样我们就能在函数声明行清清楚楚看到输入输出的类型,特别当你不想手写返回类型(想依赖类型推论)的时候,就没必要单把参数类型写到别的地方了。这种方式在函数表达式中也同样适用:

function getLen(input: string): number {
  return input.length;
}

或者,我可以让某几个参数可选

const getLen = (input: string, options?: any): number => input.length;

重载

直接注解到函数声明上的另外一个好处是,方便我们扩展函数的重载。

很多情况下,我们的函数不只有一种传参形式,比如类似 CSS 里的padding,我可以给 1个数、2个数、4个数是吧,那我怎么声明呢?

function padding(a: number, b?: number, c?: number, d?: number): string => {...};

这里有几个问题:

  1. 这种声明方式允许我传三个参数,那么类型约束就不够严谨;
  2. 因为传参数量不同的时候,参数的意义也不同,在别人调用我函数的时候,我就没法通过参数名明确告诉他每个参数是什么意思,只能 abcd 这么笼统地来。

padding 的例子:

// 重载的三种声明头
function padding(all: number);
function padding(topAndBottom: number, leftAndRight: number);
function padding(top: number, right: number, bottom: number, left: number);
// 实际用的头,且不作为类型注解向外暴露
function padding(a: number, b?: number, c?: number, d?: number) {
  if (b === undefined && c === undefined && d === undefined) {
    b = c = d = a;
  } else if (c === undefined && d === undefined) {
    c = a;
    d = b;
  }
  return {
    top: a,
    right: b,
    bottom: c,
    left: d
  };
}

2 类型推断

在 JS 中,变量创建是非常频繁的,创建方式也五花八门,如果我每次搞到一个变量都要通过注解的方式给它类型,不是要累死?值得高兴的是,TS考虑到了这个问题,在很多场景下,它会根据你「变量声明空间」的变量流转逻辑,帮你“猜”出新变量的类型。

这听起来就像我有一个宠物叫 Tony,并且已经注解它是只猫,那么 Tony 下了个崽叫 Tim,系统有理由推断 Tim 就是只猫。

从右向左

这么好的事,哪些场景可以自动推断呢?——一切可以从赋值和变量流转追溯到类型的地方。所谓的赋值和流转,离不开等号,TS会尝试从右侧推断出左侧的类型。

直接赋值

当变量创建的时候就赋值,TS便可以由赋值概括出它的类型。

const foo = 1;  // foo 的类型被推断为 1

那如果我只声明不赋值也不注解会怎么样?变量会被推断为 any,这对一个类型系统是无意义的,TS 会提醒你不要这么做。

结构化

这种「从赋值推断类型的能力」是可以嵌套的。如果你给变量赋了一个复杂的值,TS也会基于基础类型的判断,嵌套下去,归纳出一个类型:

const bar = {       // bar 的类型被推断为接口 { baz: string; }
    baz: '',
};

数组也是一种结构化数据:

const foo = [ 1, '2' ]; // foo 被推断为 (string | number)[]

可能你有疑问,为什么 foo 不被推断为[ number, string ]的元组?这涉及TS推论的方法,叫做“最佳通用类型”。

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

相反,解构也会推断类型,以数组为例:

const foo = [ 1, 2 ];
const bar = foo[0]; // bar 被推断为 number

运算

如果等号右边是一个运算表达式,TS同样可以推断出变量的类型:

const foo = 1 + 1;  // foo 被推断为 number
const bar = 1 + '1';    // bar 被推断为 string,TS 甚至知道强制类型转换的规则

但我们要说的运算不止于此,任何已知类型变量的运算结果都可以被推断:

let foo: number;
let bar: string;
const baz = foo + bar;  // baz 被推断为 string

函数

从某种意义上说,函数赋值也是一个结构化的定义。函数类型中的“返回值”部分,可以像结构化一样推断出来。

const foo = () => { // foo 被推断为 () => number
    return 1;
};

同样的,返回值也可以依赖内部变量类型或者参数类型推断出来。

const foo = (bar: number) => {  // foo 被推断为 () => number
    const baz = 2;
    return bar + baz;
};

但函数内部的情况会更复杂,所以出现了一些针对函数返回值的特殊类型,诸如 void、never。

const foo = () => { // foo 没有返回,被推断为 () => void
    doSomething();
};
const bar = () => { // bar 总执行不完,被推断为 () => never
    throw new Error();
};

Error

类型推断除了用于确定未注解变量的类型,也用于及时判断出不合理的类型赋值。比如:

const foo: number = ''; // Error: 不能将类型“string”分配给类型“number”

从左向右

相反,如果等号左侧已经确定了类型,右侧的赋值也会吸收左侧的类型,并尝试约束自己的行为。由于右侧的值与所处上下文强相关,这种特性叫做“按上下文归类”。比如这样一个函数:

let foo: (bar: number) => number; // 已经注解 foo 的类型
foo = (bar) => {        // 这里的函数赋值会根据所处上下文,吸收 foo 的已定类型,并解析到参数和返回值中
    return bar.length   // Error: 类型“number”上不存在属性“length”
};
上下文归类会在很多情况下使用到。 通常包含函数的参数,赋值表达式的右边,类型断言,对象成员和数组字面量和返回值语句。

关于包装对象

另外一个点是关于包装对象的。我们知道在「变量声明空间」创建一个字符串值有几种方法。不同声明方法默认推断成不同类型:string 或 String。

var foo = new String("Avoid newing things where possible"); // String
var bar = "A string, in TypeScript of type 'string'";       // string
var baz = String('aaa');    // string
“string”是基元,但“String”是包装器对象。如可能首选使用“string”。

string 是 ts 原始类型值,String 则指向 es5.d.ts 中定义的 interface,你可以想象到它的实现方式:

// es5.d.ts
interface String {
    valueOf(): string;  
}
interface StringConstructor {
    new (value): String;
    (value): string;
}
declare const String: StringConstructor;

只能把 string 赋值给 String,不能把 String 赋值给 string。

foo = bar   // 正常
bar = foo   // 不能将类型“String”分配给类型“string”。“string”是基元,但“String”是包装器对象。如可能首选使用“string”。

3 类型断言

狗狗 Tony 的崽 Tim 被推断为狗,但我知道 Tim 不仅是只狗,还是只“泰迪”(“狗”的子类型),并想进一步缩小标记的类型范围。

是的,我比编译器懂得更多,我清楚地知道一个实体具有比它现有类型更确切的类型。我需要一种方式,让我推翻编译器的推断。这就叫“类型断言”。as<>都能用来做断言:

const foo: any;
(foo as string).length;
<string>foo.length;

类型断言通常用在:缩小类型范围、

断言的限制

赵高让人牵来一只鹿,满脸堆笑地对秦二世说:“陛下,我献给您一匹好马。”

如果不加以限制,断言就成了“指鹿为马”,随意破坏 TS 的类型环境。所以断言只在部分合理的场景下可用,具体来说:

  • 如果 A 类型兼容 B 类型,那么 A、B 可以相互断言
  • 顶层类型(any / unknown)可以和任意类型相互断言
  • 联合类型可以和任意子集相互断言
TypeScript里的类型兼容性是基于结构子类型的。 结构类型是一种只使用其成员来描述类型的方式。TypeScript结构化类型系统的基本规则是,如果x要兼容y,那么y至少具有与x相同的属性。

其中关于兼容,TS 这样解释的,说白了就是不管类型怎么定义的,只要一方满足另一方定义的所有属性,就是兼容的。

下面我们回到正题,通过以下几个例子看下断言的限制:

let foo: number;                foo as any;                     // ok, 顶层类型
let foo: any;               foo as number;                  // ok, 顶层类型
let foo: number | string;   foo as number;                  // ok, 联合类型
interface Parent { p: string }
interface Child extends Parent { c: number }
let foo: Parent;                foo as Child;                   // ok, 兼容
let foo = [1, 'a'];         foo as [number, number];        // ok, 联合类型

let foo: number;                foo as string;                  // 不 ok, 指鹿为马
interface Parent { p: string }
interface Child { c: number }
let foo: Parent;                foo as Child;                   // 不 ok, 不兼容

双重断言

赵高让人牵来一只鹿,满脸堆笑地对秦二世说:“陛下,我献给您一个鹿或马。”稍作沉默,他又说:“陛下,这动物是只马”。

这就是断言的风险,我先把类型断言为一个宽松的中间类型,又断言到一个本来不可以直接断言到的类型,仍然可以达到“指鹿为马”的效果。

let foo = 1;    foo as string;                          // 不 ok,类型 "number" 到类型 "string" 的转换可能是错误的,如果这是有意的,请先将表达式转换为 "unknown”。
let foo = 1;    foo as number | string as string;       // ok, 联合类型成了中间类型

但在上面第一句的报错中,TS 也提到“如果这是有意的,请先将表达式转换为 "unknown””。可见TS对这个风险是充分知晓的,甚至是如 any 一样有意留的“后门”,而在双重断言中,作为「中间类型」的更常见也更方便的是 any 和 unknown。但不论怎样,我们应尽量减少双重断言的使用。

4 类型保护

赵高满脸堆笑地对秦二世说:“陛下,我献给您一个鹿或马。”秦二世摸了摸这个动物的脑袋:“没有角,是只马,骑”。

我们品一品这里发生了什么,秦二世首先判断了联合类型「鹿|马」变量传入的值是否存在「角」这个属性,然后类型断言为「马」,最后调用了「马」的方法,说得通吧。

const whatQinIIThink = (ani: Deer|Horse) => {
    if (!('角' in ani)) {
        const hor = (ani as Horse)
        hor.ride();
    }
}

事实上,TS 更加智能,当!('角' in ani)满足时,就自动把「鹿」从 ani 的联合类型中刨出去了,不需要手动断言缩小范围。

秦二世:“没有角!骑!”。

这种特性叫类型保护:在某些 JS 语句下,尽可能把类型保护在更小范围更精确的类型中。

触发类型保护的方式

哪些JS语句可以触发类型保护呢?

// 1. typeof
function doSome(x: number | string) {
  if (typeof x === 'string') {
    // 在这个块中,TypeScript 知道 `x` 的类型必须是 `string`
    console.log(x.substr(1)); // ok
  }
}

// 2. instanceof
class Foo { foo = 123 }
class Bar { bar = 123 }
function doStuff(arg: Foo | Bar) {
  if (arg instanceof Foo) {
    console.log(arg.foo); // ok
    console.log(arg.bar); // Error
  }
}

// 3. in
interface A {x: number}
interface B {y: string}
function doStuff(q: A | B) {
  if ('x' in q) {
    // q: A
  }
}

// 4. 字面量
type Foo = {    kind: 'foo'; // 字面量类型   };
type Bar = {    kind: 'bar'; // 字面量类型   };
function doStuff(arg: Foo | Bar) {
  if (arg.kind === 'foo') {
    console.log(arg.foo); // ok
    console.log(arg.bar); // Error
  }
}

自定义的类型保护

但上面的语句只能进行简单判断,有时候情况更复杂。

秦二世认为单凭角不能区分鹿和马,所以脑海里封装了一个逻辑,有角有花纹没鬃的是鹿。

这时,只要把判断函数的返回值声明为foo is Type形式,调用时就会触发TS的类型保护。

function isDeer (ani: Deer|Horse): ani is Deer {
    return ('角' in ani) && ('花纹' in ani) && !('鬃' in ani);
}
const whatQinIIThink = (ani: Deer|Horse) => {
    if (!isDeer(ani)) {
        hor.ride();     // ok
    }
}

小结

在从「类型声明空间」向「变量声明空间」做注解这件事上,TS做了很多纠结又恰到好处的决定。兼顾了类型系统的严格性和开发的灵活性,权衡了“自动”带来的效率和准确度。

5 类型捕获

最后,我们再来看下,类型如何从「变量声明空间」反向流转到「类型声明空间」。这种行为叫做类型捕获。


类型捕获很简单,是通过 typeof 实现的。

let foo = 123;
let bar: typeof foo; // 'bar' 类型与 'foo' 类型相同(在这里是: 'number')
bar = 456; // ok
bar = '789'; // Error: 'string' 不能分配给 'number' 类型

有些情况下,类型捕获还会和上一篇提到的类型运算结合:

const colors = {
  red: '1',
  blue: '2'
};
type Colors = keyof typeof colors;  // 'red' | 'blue'

小结

本篇我们讨论类型在两个空间之间的交流

  • 「变量声明空间」的声明产物都可以注解为「类型声明空间」的类型
  • 函数可以通过重载注解多种参数形式,实现调用的灵活准确
  • TS 会为声明的变量推断类型,推断的依据来自于对变量赋值、运算、流转的追溯
  • 在有限的场景下,开发者也可以通过类型断言,进行已有类型注解或推断的修正
  • TS 会根据 JS 条件语句,自动缩小联合类型的范围,进行类型保护
  • 如果需要从变量反向取出它的类型,用 typeof 捕获就好
编辑于 2021-08-18 17:25