TypeScript – 进入类型的世界:理解原始类型与对象类型

TypeScript 全面进阶指南专栏目录总览

通常来说,学习一件新事物的较好方式是和你已掌握的做对比,通过二者之间通用的概念帮你快速熟悉新的事物。比如,在掌握了 Vue 以后再去学习 React,对于组件通信、状态管理、插槽等这些二者共有的概念,你会感到非常熟悉。同样的,这一章我们会从 JavaScript 的既有概念开始学习,详细讲解 TypeScript 所有原始类型、数组以及对象的类型标注,让你能快速对 TypeScript 的功能、语法有一个基础认知。

这一章会包含几乎所有与原始类型、对象类型、数组等强相关的知识点,这么设计主要是因为它们之间的内容环环相扣,而且也不难,放在一起能帮助你一次性建立相对完整的知识体系

如果你已经对这一章的知识点有基本了解或者非常熟悉了,那我建议你利用它来查缺补漏,为后面的学习打好基础,比如元组就是一个很容易被大家忽视的知识点。

话不多说,跟着我一起往下看吧!

本节代码见:Primitive and Object

原始类型的类型标注

首先,我们来看 JavaScript的内置原始类型。除了最常见的 number / string / boolean / null / undefined, ECMAScript 2015(ES6)、2020 (ES11) 又分别引入了 2 个新的原始类型:symbol 与 bigint 。在 TypeScript 中它们都有对应的类型注解:

const name: string = 'linbudu';
const age: number = 24;
const male: boolean = false;
const undef: undefined = undefined;
const nul: null = null;
const obj: object = { name, age, male };
const bigintVar1: bigint = 9007199254740991n;
const bigintVar2: bigint = BigInt(9007199254740991);
const symbolVar: symbol = Symbol('unique');

其中,除了 null 与 undefined 以外,余下的类型基本上可以完全对应到 JavaScript 中的数据类型概念,因此这里我们只对 null 与 undefined 展开介绍。

null 与 undefined

在 JavaScript 中,null 与 undefined 分别表示“这里有值,但是个空值”和“这里没有值”。而在 TypeScript 中,null 与 undefined 类型都是有具体意义的类型。也就是说,它们作为类型时,表示的是一个有意义的具体类型值。这两者在没有开启 strictNullChecks 检查的情况下,会被视作其他类型的子类型,比如 string 类型会被认为包含了 null 与 undefined 类型:

const tmp1: null = null;
const tmp2: undefined = undefined;

const tmp3: string = null; // 仅在关闭 strictNullChecks 时成立,下同
const tmp4: string = undefined;

除了上面介绍的原始类型以及 null、undefined 类型以外,在 TypeScript 中还存在着一个特殊的类型:void,它和 JavaScript 中的 void 同样不是一回事,我们接着往下看。

void

你是否看到过以下的 JavaScript 代码呢?

<a href="javascript:void(0)">清除缓存</a>

这里的 void(0) 等价于 void 0,即 void expression 的语法。void 操作符会执行后面跟着的表达式并返回一个 undefined,如你可以使用它来执行一个立即执行函数(IIFE):

void function iife() {
  console.log("Invoked!");
}();

能这么做是因为,void 操作符强制将后面的函数声明转化为了表达式,因此整体其实相当于:void((function iife(){})())

事实上,TypeScript 的原始类型标注中也有 void,但与 JavaScript 中不同的是,这里的 void 用于描述一个内部没有 return 语句,或者没有显式 return 一个值的函数的返回值,如:

function func1() {}
function func2() {
  return;
}
function func3() {
  return undefined;
}

在这里,func1 与 func2 的返回值类型都会被隐式推导为 void,只有显式返回了 undefined 值的 func3 其返回值类型才被推导为了 undefined。但在实际的代码执行中,func1 与 func2 的返回值均是 undefined。

虽然 func3 的返回值类型会被推导为 undefined,但是你仍然可以使用 void 类型进行标注,因为在类型层面 func1、func2、func3 都表示“没有返回一个有意义的值”。

这里可能有点绕,你可以认为 void 表示一个空类型,而 null 与 undefined 都是一个具有意义的实际类型(注意与它们在 JavaScript 中的意义区分)。而 undefined 能够被赋值给 void 类型的变量,就像在 JavaScript 中一个没有返回值的函数会默认返回一个 undefined 。null 类型也可以,但需要在关闭 strictNullChecks 配置的情况下才能成立。

const voidVar1: void = undefined;

const voidVar2: void = null; // 需要关闭 strictNullChecks

到这里,我们了解了 JavaScript 中原始数据类型到 TypeScript 原始类型概念地映射,你应当能感觉到 TypeScript 对 JavaScript 开发者的友好,大部分概念都能比较自然地过渡,下面的数组与对象的类型标注同样如此。

数组的类型标注

数组同样是我们最常用的类型之一,在 TypeScript 中有两种方式来声明一个数组类型:

const arr1: string[] = [];

const arr2: Array<string> = [];

这两种方式是完全等价的,但其实更多是以前者为主,如果你将鼠标悬浮在 arr2 上,会发现它显示的类型签名是 string[]。数组是我们在日常开发大量使用的数据结构,但在某些情况下,使用 元组(Tuple) 来代替数组要更加妥当,比如一个数组中只存放固定长度的变量,但我们进行了超出长度地访问:

const arr3: string[] = ['lin', 'bu', 'du'];

console.log(arr3[599]);

这种情况肯定是不符合预期的,因为我们能确定这个数组中只有三个成员,并希望在越界访问时给出类型报错。这时我们可以使用元组类型进行类型标注:

const arr4: [string, string, string] = ['lin', 'bu', 'du'];

console.log(arr4[599]);

此时将会产生一个类型错误:长度为“3”的元组类型“[string, string, string]”在索引“599“处没有元素。除了同类型的元素以外,元组内部也可以声明多个与其位置强绑定的,不同类型的元素:

const arr5: [string, number, boolean] = ['linbudu', 599, true];

在这种情况下,对数组合法边界内的索引访问(即 0、1、2)将精确地获得对应位置上的类型。同时元组也支持了在某一个位置上的可选成员:

const arr6: [string, number?, boolean?] = ['linbudu'];
// 下面这么写也可以
// const arr6: [string, number?, boolean?] = ['linbudu', , ,];

对于标记为可选的成员,在 --strictNullCheckes 配置下会被视为一个 string | undefined 的类型。此时元组的长度属性也会发生变化,比如上面的元组 arr6 ,其长度的类型为 1 | 2 | 3

type TupleLength = typeof arr6.length; // 1 | 2 | 3

也就是说,这个元组的长度可能为 1、2、3。

关于类型别名(type)、类型查询(typeof)以及联合类型,我们会在后面讲到,这里你只需要简单了解即可。

你可能会觉得,元组的可读性实际上并不好。比如对于 [string, number, boolean]来说,你并不能直接知道这三个元素都代表什么,还不如使用对象的形式。而在 TypeScript 4.0 中,有了具名元组(Labeled Tuple Elements)的支持,使得我们可以为元组中的元素打上类似属性的标记:

const arr7: [name: string, age: number, male: boolean] = ['linbudu', 599, true];

有没有很酷?考虑到某些拼装对象太麻烦,我们完全可以使用具名元组来做简单替换。具名元组可选元素的修饰符将成为以下形式:

const arr7: [name: string, age: number, male?: boolean] = ['linbudu', 599, true];

实际上除了显式地越界访问,还可能存在隐式地越界访问,如通过解构赋值的形式:

const arr1: string[] = [];

const [ele1, ele2, ...rest] = arr1;

对于数组,此时仍然无法检查出是否存在隐式访问,因为类型层面并不知道它到底有多少个元素。但对于元组,隐式的越界访问也能够被揪出来给一个警告:

const arr5: [string, number, boolean] = ['linbudu', 599, true];

// 长度为 "3" 的元组类型 "[string, number, boolean]" 在索引 "3" 处没有元素。
const [name, age, male, other] = arr5;

JavaScript 的开发者对元组 Tuple 的概念可能比较陌生,毕竟在 JavaScript 中我们很少声明定长的数组。但使用元组确实能帮助我们进一步提升数组结构的严谨性,包括基于位置的类型标注、避免出现越界访问等等。除了通过数组类型提升数组结构的严谨性,TypeScript 中的对象类型也能帮助我们提升对象结构的严谨性。接下来我们就一起来看看。

对象的类型标注

作为 JavaScript 中使用最频繁的数据结构,对象的类型标注是我们本节要重点关注的部分。接下来我们会学习如何在 TypeScript 中声明对象、修饰对象属性,以及了解可能存在的使用误区。这些内容能够帮助你建立起对 TypeScript 中立体类型(我们可以理解为前面的原始类型是“平面类型”)的了解,正式入门 TypeScript 。

类似于数组类型,在 TypeScript 中我们也需要特殊的类型标注来描述对象类型,即 interface ,你可以理解为它代表了这个对象对外提供的接口结构。

首先我们使用 interface 声明一个结构,然后使用这个结构来作为一个对象的类型标注即可:

interface IDescription {
  name: string;
  age: number;
  male: boolean;
}

const obj1: IDescription = {
  name: 'linbudu',
  age: 599,
  male: true,
};

这里的“描述”指:

  • 每一个属性的值必须一一对应到接口的属性类型
  • 不能有多的属性,也不能有少的属性,包括直接在对象内部声明,或是 obj1.other = 'xxx' 这样属性访问赋值的形式

除了声明属性以及属性的类型以外,我们还可以对属性进行修饰,常见的修饰包括可选(Optional) 与 只读(Readonly) 这两种。

修饰接口属性

类似于上面的元组可选,在接口结构中同样通过 ? 来标记一个属性为可选:

interface IDescription {
  name: string;
  age: number;
  male?: boolean;
  func?: Function;
}

const obj2: IDescription = {
  name: 'linbudu',
  age: 599,
  male: true,
  // 无需实现 func 也是合法的
};

在这种情况下,即使你在 obj2 中定义了 male 属性,但当你访问 obj2.male 时,它的类型仍然会是 boolean | undefined,因为毕竟这是我们自己定义的类型嘛。

假设新增一个可选的函数类型属性,然后进行调用:obj2.func() ,此时将会产生一个类型报错:不能调用可能是未定义的方法。但可选属性标记不会影响你对这个属性进行赋值,如:

obj2.male = false;
obj2.func = () => {};

即使你对可选属性进行了赋值,TypeScript 仍然会使用接口的描述为准进行类型检查,你可以使用类型断言、非空断言或可选链解决(别急,我们在后面会讲到)。

除了标记一个属性为可选以外,你还可以标记这个属性为只读:readonly。很多同学对这一关键字比较陌生,因为以往 JavaScript 中并没有这一类概念,它的作用是防止对象的属性被再次赋值

interface IDescription {
  readonly name: string;
  age: number;
}

const obj3: IDescription = {
  name: 'linbudu',
  age: 599,
};

// 无法分配到 "name" ,因为它是只读属性
obj3.name = "林不渡";

其实在数组与元组层面也有着只读的修饰,但与对象类型有着两处不同。

  • 你只能将整个数组/元组标记为只读,而不能像对象那样标记某个属性为只读。
  • 一旦被标记为只读,那这个只读数组/元组的类型上,将不再具有 push、pop 等方法(即会修改原数组的方法),因此报错信息也将是类型 xxx 上不存在属性“push”这种。这一实现的本质是只读数组与只读元组的类型实际上变成了 ReadonlyArray,而不再是 Array。

type 与 interface

我也知道,很多同学更喜欢用 type(Type Alias,类型别名)来代替接口结构描述对象,而我更推荐的方式是,interface 用来描述对象、类的结构,而类型别名用来将一个函数签名、一组联合类型、一个工具类型等等抽离成一个完整独立的类型。但大部分场景下接口结构都可以被类型别名所取代,因此,只要你觉得统一使用类型别名让你觉得更整齐,也没什么问题。

object、Object 以及 { }

objectObject 以及{}(一个空对象)这三者的使用可能也会让部分同学感到困惑,所以我也专门解释下。

首先是 Object 的使用。被 JavaScript 原型链折磨过的同学应该记得,原型链的顶端是 Object 以及 Function,这也就意味着所有的原始类型与对象类型最终都指向 Object,在 TypeScript 中就表现为 Object 包含了所有的类型:

// 对于 undefined、null、void 0 ,需要关闭 strictNullChecks
const tmp1: Object = undefined;
const tmp2: Object = null;
const tmp3: Object = void 0;

const tmp4: Object = 'linbudu';
const tmp5: Object = 599;
const tmp6: Object = { name: 'linbudu' };
const tmp7: Object = () => {};
const tmp8: Object = [];

和 Object 类似的还有 Boolean、Number、String、Symbol,这几个装箱类型(Boxed Types) 同样包含了一些超出预期的类型。以 String 为例,它同样包括 undefined、null、void,以及代表的 拆箱类型(Unboxed Types) string,但并不包括其他装箱类型对应的拆箱类型,如 boolean 与 基本对象类型,我们看以下的代码:

const tmp9: String = undefined;
const tmp10: String = null;
const tmp11: String = void 0;
const tmp12: String = 'linbudu';

// 以下不成立,因为不是字符串类型的拆箱类型
const tmp13: String = 599; // X
const tmp14: String = { name: 'linbudu' }; // X
const tmp15: String = () => {}; // X
const tmp16: String = []; // X

在任何情况下,你都不应该使用这些装箱类型。

object 的引入就是为了解决对 Object 类型的错误使用,它代表所有非原始类型的类型,即数组、对象与函数类型这些

const tmp17: object = undefined;
const tmp18: object = null;
const tmp19: object = void 0;

const tmp20: object = 'linbudu';  // X 不成立,值为原始类型
const tmp21: object = 599; // X 不成立,值为原始类型

const tmp22: object = { name: 'linbudu' };
const tmp23: object = () => {};
const tmp24: object = [];

最后是{},一个奇奇怪怪的空对象,如果你了解过字面量类型,可以认为{}就是一个对象字面量类型(对应到字符串字面量类型这样)。否则,你可以认为使用{}作为类型签名就是一个合法的,但内部无属性定义的空对象,这类似于 Object(想想 new Object()),它意味着任何非 null / undefined 的值:


const tmp25: {} = undefined; // 仅在关闭 strictNullChecks 时成立,下同
const tmp26: {} = null;
const tmp27: {} = void 0; // void 0 等价于 undefined

const tmp28: {} = 'linbudu';
const tmp29: {} = 599;
const tmp30: {} = { name: 'linbudu' };
const tmp31: {} = () => {};
const tmp32: {} = [];

虽然能够将其作为变量的类型,但你实际上无法对这个变量进行任何赋值操作

const tmp30: {} = { name: 'linbudu' };

tmp30.age = 18; // X 类型“{}”上不存在属性“age”。

这是因为它就是纯洁的像一张白纸一样的空对象,上面没有任何的属性(除了 toString 这种与生俱来的)。在类型层级一节我们还会再次见到它,不过那个时候它已经被称为“万物的起源”了。

最后,为了更好地区分 Objectobject 以及{}这三个具有迷惑性的类型,我们再做下总结:

  • 在任何时候都不要,不要,不要使用 Object 以及类似的装箱类型。
  • 当你不确定某个变量的具体类型,但能确定它不是原始类型,可以使用 object。但我更推荐进一步区分,也就是使用 Record<string, unknown> 或 Record<string, any> 表示对象,unknown[] 或 any[] 表示数组,(...args: any[]) => any表示函数这样。
  • 我们同样要避免使用{}{}意味着任何非 null / undefined 的值,从这个层面上看,使用它和使用 any 一样恶劣。

总结与预告

这一节,我们一起学习了 TypeScript 中原始类型、对象类型、数组(元组)的类型标注,以及对数组的只读、对象类型属性的访问性修饰。这里的知识其实可以分为两类:

  • 与 JavaScript 概念基本一致的部分,如原始类型与数组类型需要重点掌握,但因为思维方式基本没有变化,所以你可以认为你就是在写更严格一些的 JavaScript
  • 一些全新的概念,比如元组与 readonly 修饰等,这一部分你可能不会很快适应,需要稍微转换一下思维方式。我建议你可以从现在开始,有意识地在日常开发中去多多使用它们。

另外,对于 readonly 这一修饰符,JavaScript 开发者可能需要一定的时间来理解和习惯,但它在工程层面确实是非常推荐的一种实践,可以使用只读标记来避免数组和对象被错误修改。当然,TypeScript 目前只能够帮助你在编译时做检查,类型信息在编译后都会被擦除,所以 readonly 并不会在实际运行时报错。

学习完这一小节后,不妨找出你曾经的 JavaScript 项目,试试用本章学到的知识为这些 JavaScript 代码添加一些类型,再把某些场景下的数组换成元组,为部分对象类型的属性添加 readonly,来感受 TypeScript 代码的严格之美。

在下一节我们要介绍的字面量类型以及枚举,在某些方面其实可以理解为是原始类型与对象类型的进一步延伸,也同样是日常会被重度使用的语法。在完成下一节的学习后,你就可以开始进一步地改造你的 JavaScript 项目,让那些类型变得更精确一些!

扩展阅读

unique symbol

Symbol 在 JavaScript 中代表着一个唯一的值类型,它类似于字符串类型,可以作为对象的属性名,并用于避免错误修改 对象 / Class 内部属性的情况。而在 TypeScript 中,symbol 类型并不具有这一特性,一百个具有 symbol 类型的对象,它们的 symbol 类型指的都是 TypeScript 中的同一个类型。为了实现“独一无二”这个特性,TypeScript 中支持了 unique symbol 这一类型声明,它是 symbol 类型的子类型,每一个 unique symbol 类型都是独一无二的。

const uniqueSymbolFoo: unique symbol = Symbol("linbudu")

// 类型不兼容
const uniqueSymbolBar: unique symbol = uniqueSymbolFoo

在 JavaScript 中,我们可以用 Symbol.for 方法来复用已创建的 Symbol,如 Symbol.for("linbudu") 会首先查找全局是否已经有使用 linbudu 作为 key 的 Symbol 注册,如果有,则返回这个 Symbol,否则才会创建新的 Symbol 。

在 TypeScript 中,如果要引用已创建的 unique symbol 类型,则需要使用类型查询操作符 typeof :

declare const uniqueSymbolFoo: unique symbol;

const uniqueSymbolBaz: typeof uniqueSymbolFoo = uniqueSymbolFoo

这里的 declare、typeof 等使用,都会在后面有详细地讲解。同时 unique symbol 在日常开发的使用非常少见,这里做了解就好~

免责声明:
1.本站所有内容由本站原创、网络转载、消息撰写、网友投稿等几部分组成。
2.本站原创文字内容若未经特别声明,则遵循协议CC3.0共享协议,转载请务必注明原文链接。
3.本站部分来源于网络转载的文章信息是出于传递更多信息之目的,不意味着赞同其观点。
4.本站所有源码与软件均为原作者提供,仅供学习和研究使用。
5.如您对本网站的相关版权有任何异议,或者认为侵犯了您的合法权益,请及时通知我们处理。
火焰兔 » TypeScript – 进入类型的世界:理解原始类型与对象类型