上一篇我们了解了 TS 在「类型声明空间」的行为,那「类型声明空间」的产物是如何约束「变量声明空间」的,「变量声明空间」又能为「类型声明空间」提供哪些信息呢?这就是本篇要讨论的——两个空间的交流。
本文你将看到:
- 「类型声明空间」如何为「变量声明空间」的声明提供类型注解
- 哪些场景下TS会“自动注解”?推断的规则又是怎样的?
- 如果变量已有或推断的类型不准确,我们可以修正吗?又可能存在什么问题?
- TS 怎样通过 JS 逻辑语句自动缩小类型范围?
- 我们如何从「变量声明空间」提取类型声明?
1 类型注解
从「类型声明空间」到「变量声明空间」,最基础的,我们可以通过类型注解,为变量提供类型约束。如下图所示,我们上一篇在「类型声明空间」构造出的任何产物,都可以直接拿来注解。
基本注解
一个冒号是最基本的注解方式,它完成了从类型声明空间向变量声明空间的映射。
// 直接注解原始类型
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 => {...};
这里有几个问题:
- 这种声明方式允许我传三个参数,那么类型约束就不够严谨;
- 因为传参数量不同的时候,参数的意义也不同,在别人调用我函数的时候,我就没法通过参数名明确告诉他每个参数是什么意思,只能 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考虑到了这个问题,在很多场景下,它会根据你「变量声明空间」的变量流转逻辑,帮你“猜”出新变量的类型。
从右向左
这么好的事,哪些场景可以自动推断呢?——一切可以从赋值和变量流转追溯到类型的地方。所谓的赋值和流转,离不开等号,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推论的方法,叫做“最佳通用类型”。
相反,解构也会推断类型,以数组为例:
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 是 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 类型断言
是的,我比编译器懂得更多,我清楚地知道一个实体具有比它现有类型更确切的类型。我需要一种方式,让我推翻编译器的推断。这就叫“类型断言”。as
和 <>
都能用来做断言:
const foo: any;
(foo as string).length;
<string>foo.length;
类型断言通常用在:缩小类型范围、
断言的限制
如果不加以限制,断言就成了“指鹿为马”,随意破坏 TS 的类型环境。所以断言只在部分合理的场景下可用,具体来说:
- 如果 A 类型兼容 B 类型,那么 A、B 可以相互断言
- 顶层类型(any / unknown)可以和任意类型相互断言
- 联合类型可以和任意子集相互断言
其中关于兼容,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 捕获就好
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!