探秘Typescript·TS日常类型

探秘Typescript·TS日常类型

前言

很多初学Typescript的同学,可能对于TS中都有哪些常用的内置类型不太了解,而官网上所提供的类型虽然很全面,但有一些在我们日常开发过程中可能比较少用,并且其分类上对于初学者学习来说没那么友好。因此,初学者通常需要花费大量的时间查阅各种文档以熟悉和了解TS中常见的内置类型,严重影响开发与学习的效率。因此,这边梳理总结了一下个人在开发、学习过程中常见的一些内置类型,并对他们进行了简单的分类,让他们对于初学者的学习更加友好,希望对Typescript的初学者有所帮助。

基础类型

Typescript当中,基础数据类型包括:

  • string
  • number
  • boolean
  • null
  • undefined

以上的这些数据类型都是开发中非常常见的基础数据类型,没有太特别的地方,这边就不太赘述了。

数组类型

表示形式

Array<T>:其中T为数组中元素的类型,再此用泛型参数的形式传入,可以让我们的数组的类型可以更加灵活的扩展,如:

  • Array<string>:代表纯粹由字符串组成的数组
  • Array<number>:代表纯粹由数字组成的数组
  • Array<string|number>:代表数组中的元素可以是字符串类型,也可以是数字类型
  • Array<CustomObj>:代表由自定义结构类型CustomObj组成的数组

除了上述的表述方式之外,我们还可以用以下方式表示数组,效果实际是一样的,但写起来更加便捷,可以理解为上述类型描述的语法糖

  • string[]
  • number[]
  • (string|number)[]
  • CustomObj[]

特化——元组

除此之外,我们的数组还有一个特意化的变种,我们称之为:元组元组规定类一个有限长的的数组中每一个元素项的类型,如:

  • [string, number, boolean]:该元组代表着当前数组只能有三个元素,其中第一个元素的类型是string,第二个元素的类型是number,第三个元素的类型是boolean

如果我们在实际使用时,元素的数量或者是对应位置上元素的类型不对,我们的编辑器都会很聪明的帮我们报错提醒。有使用过React进行开发的同学,应该对useState这个内置的hooks相当熟悉了,其中useState的返回类型就是一个元组,其类型为:

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
// 上述类型描述中,S为用户实际传入的数据类型,而Dispatch<SetStateAction<S>>则是定义了更新状态的方法描述

思考

要求数组内的元素类型保持统一有什么好处呢?

  1. 在绝大多数情况下,我们在开发时使用数组,其中存储的数据类型实际上都是单一的,如果数组里面全都是字符串或者全都是数字,用泛型的方式统一数组元素的类型,可以让我们的代码更加规范和统一
  2. 数组是可以迭代遍历的,想象一下,如果你的数组里面既有字符串,又有数组。,那么你在遍历的过程中,想要调用字符串的方法substring或调用数字的方法toFixed时,就需要极为谨慎的判断每个元素的类型了,因为你无法预测其他开发者在使用你这个数组时,给你传递的元素是字符串类型的还是数字类型的,如果不加以判断,当数字类型的元素调用了substring时,那么,恭喜你,喜提一个异常。因此,统一元素类型也是为了让我们的代码更加严谨,防止出错。
  3. 当你为数组指定了统一的类型之后,无论是哪个开发者,在使用这个数组所支持的所有方法时,都能够很明确的知道,这个数组里放的都是什么东西,这比起一行行的注释来得更加高效,还能得到编辑器的语法提示的帮助。

any/implictAny/unknown

any

代表当前类型是任意数据类型,也就是说,无论你传什么值过来,都是可以被接受的,如:

let a: any = 0;
a = "kiner";
a = ["kiner"];
a = {name: "kiner"}
a();
// 从上面的代码我们可以看出,我们在声明变量a之后,给a初始化了一个数字0的值,随后分别又用字符串、数组、对象的形式覆盖了a变量的值,甚至最后把a当做方法进行调用,但上述的这个程序在编辑器中并不会报错,只有当在实际运行时,发现a并不是一个方法时,才会报错。

大家可以发现上述演示的这种用法,其实就相当于没有使用Typescript的类型约束,而是存粹用javascript的开发思维在写代码了,这样写虽然不会报错,而且能够正常运行,但是我们不推荐这样使用。因为:

  • 失去了类型约束,让变量a的类型可以千变万化,看似更加灵活自由,实则为后续的开发维护埋下了巨大的隐患,谁都不知道谁会不会在某一个犄角旮旯里,把a变成了其他的类型的变量,但你完全不知情,依然还把a当做是数字来处理,这无疑是一个定时炸弹。
  • 没有了类型的约束,我们的编辑器也不知道你这个变量到底是什么类型的,因为他可以是任何类型,也没办法给你一些语法提示,用以提升效率。
  • 即使有很明显的语法错误,如a.substring(0,2)a是数字时,明显是有问题的,但因为你并没有告诉编辑器你这个变量到底是什么类型的,他想给你提示也是“巧妇难为无米之炊

implictAny

字面意思理解就是隐式的any,如:

let a;
// 上面代码中,我们定义了变量a,并且没有为a显示地指定任何数据类型,此时,Typescript会把这个变量当做是any来处理,也就是即使你没有显式地指定a的类型,也没有提供变量的初始值,Typescript无法判断这个类型究竟是什么类型的,因此会给这个变量隐式的分配一个any类型

探秘Typescript·TS日常类型

Typescript中,你可以通过配置文件tsconfig.json规定项目中是否可以使用隐式的any

// tsconfig.json
{
	"compilerOptions": {
    "noImplictAny": true
  }
}

如上述配置指定后,我们项目中如果有没有显示指定任何类型,并且没有定义变量时未赋予初始值的情况,编辑器会直接报错,用以提示用户需要指定类型。

探秘Typescript·TS日常类型

如果加上上面的配置之后将会变成这样(PS: 如果使用的开发者工具是VsCode的话,修改了tsconfig.json后,没有生效,可尝试重启VsCode,因为编辑器并不是实时的监控这个文件的)

探秘Typescript·TS日常类型

unknown

字面意识就是我现在还不知道这个类型是什么类型的,如下示例:

let a: unknown = 1;
a = "kiner";
a = false;
// 上述的操作都是允许的,a可以被赋值任意类型

// 但以下操作是不被允许的,这是是unknown与any的最大区别
let b: string = a;

探秘Typescript·TS日常类型

如果上述的变量a的类型不是unknown,而是any,那么后面赋值的时候就不会报错了,因为any类型的变量可以赋值给任意类型。

思考

为什么有了any还要提供unknown类型呢?

我们可以把unknown当做是any的一个替代品,因为一个变量,你要是指定为any了,那么这个变量就完全失去了Typescript的校验能力了,而通过上述的示例我们可以看出,相较于anyunknown还是会保留一些类型校验的能力的,用于确保在大多数情况下,替代any的能力,并做好最后一道类型安全守护。

除此之外,你把一个变量设置成unknown时,类型失效的影响范围只在当前这个变量,不会污染其他变量,如上面实例中,a如果能够赋值给b,呢么就相当于污染了变量b,在使用any时,是可以直接赋值的,因此会造成污染,导致类型失效的影响范围在变量的不断传递中逐步扩大,从而影响整个项目。

所以,我们在大多数情况下,都可以常使用unknown来替代any,以获得更好的开发与维护体验。

类型标注

Typescript中,我们会用:来标注一个变量的类型,如:

const a: string = "kiner";
const b: number = 20;
const c: (name: string) => string = (name) => `hi, ${name}`

当然,在Typescript中,如果你没有显式的指定类型,但指定了该变量的初始值,那么,我们聪明的Typescript引擎也会隐式地帮你分析出来当前变量的类型,如:

// Typescript 引擎会自动根据初始值推断 name 的类型是 string
let name = "kiner";
// 自动推断 favs 的类型为 string[]
let favs = ["kiner", "tang"];

// 那么,我们再来思考一下,下面的类型,Typescript引擎会推断成什么?
let age = 20;
const year = 2022;
const company = "netease";

// 或许很多小伙伴会说,这还不简单,分别是:number,number,string
// 如果你真的这么想,那你就太天真了。
// 不知道大家有没有注意到上述变量定义的细节,就是有些使用 let 定义,而有些则用 const 定义,有些同学可能会问,这有什么区别吗?
// 当然有区别了,大家再来复习一些 let 和 const 的一个最大区别就是 const 在变量初始化后,是不能再更改他的值的,通常被用于常量的定义,而 let 则是没有这个限制。
// 那么,如果我们使用 let 定义的一个变量,我们后面其实是可以修改他的值的,比如说将 age 改为 28,因此,Typescript 认为,使用 let 定义的 age 变量的类型应该是 number。
// 而使用 const 定义的 year,说明 year 变量恒定为 2022 ,后续代码中不会再对这个变量进行重新赋值了,因此,Typescript 引擎认为,year 的类型应该是 2022 这个数字。
// 那么,同理,他家再来猜一下 company 的类型是什么呢?详细聪明的你应该已经反应过来了,此处的类型不是 string,而是 "netease"

函数

Typescript中,有以下两种方式定义函数类型:

// 第一种
function showName(name: string): void {
  console.log(`hi, ${name}`);
}

// 第二种
const showAge: (age: number)=>void = age => `I'm ${age} year old!`;

我们可以看到,上述第一种是我们常规的函数定义方式,而第二种则是定义变量的形式,只是变量的值是一个函数。

在定义函数时,我们通常需要约束函数的传入参数返回值的类型,如:

const users: Record<number, string> = {
  0: 'kiner',
  1: 'tang',
  2: 'netease',
  3: 'youdao'
}
function getUserNameById(id: number): string {
  return users[id];
}
getUserNameById(1);// tang
getUserNameById("1234");// Error

// 上述方法中,描述了传入参数的类型是数字类型,而返回结果的类型是一个字符串

匿名函数的类型

TypescriptJavascript中,除了上述两种函数的定义方式外,还有一种特立独行的类型,就是匿名函数,而在匿名函数当中,则有一个感念需要了解一下:上下文类型 contexture typing,举个例子:


const names = ["kiner", "tang", "netease", "youdao"];
names.forEach(s => console.log(s.toLocaleUpperCase()));
// 大家觉得在 forEach 中的匿名函数中,传入参数 s 的类型是什么呢?
// 很显然,s 的类型是字符串类型,那么,这个类型是从哪里得到的呢?
// 由于我们现在是对一个字符串数组 names 进行遍历,那么 Typescript 就会为我们根据上下文的类型进行推断,发现遍历的是字符串类型的数组,那么其中的每一项的类型就应该是字符串类型。因此,即使我们没有显式的指定 s 的类型,我们也可以很安全的使用所有字符串支持的方法。

函数的可选参

JavascriptTypescript中,都存在可选参数的概念,即我虽然定义的时候定义了这个参数,但是你在使用的时候,可以选择不传这个参数,举个例子:

function showInfo(name: string, age?: number): void {
  let str = `hi, my name is ${name}`;
  if(age!==undefined) {
    str += `,I'm ${age} year old`;
  }
  console.log(str);
}
// 如上 age 为可选参,不传也可以,使用?表示当前参数是否是一个可选参

对象类型

Typescript中,如果指定的对象的类型,我们也需要严格执行,如:

const obj: {x: number, y: number} = {
  x: 1,
  y: 1
}
obj.z = 2;// Error
// 我们可以发现,当对 obj 的对象进行描述之后,其中的属性就必须严格按照类型描述类指定,你不能新增类型描述中不存在的属性,也不能不指定类型描述中必填的属性。

// 当然对象中也可以使用可选属性标识
const obj: {x: number, y: number, z?: number} = {
  x: 1,
  y: 1
}
// 上面不会报错,因为z是一个可选的属性,不赋值也没关系,但如果想要赋值也是允许的
obj.z = 2;// 不报错

联合类型

我们开发过程中,会经常遇到一个变量可能是多种类型的,比如在React当中,React.key类型是同时支持string类型和number类型的,那么,此时就要用到:联合类型了。

let key: string | number = 'kiner';
key = 0;
key = false;// Error
// 上面的示例中,我们可以为 key 赋值一个字符串或数字,但除此之外的值,依然是不被接受的

需要特别强调的是,一旦我们使用的联合类型,那么我们只能使用这两种类型公用的方法和属性,还是上面的示例:

function fn(key: string | number): void {
  key.substring(0, 2); // Error
  // 上面的代码会报错,因为 substring 方法只有 string 中才有,如果是 number 则没有这个方法
  // 但是如果调用一下方法是可以的
  key.toString();
  // 上面的代码不会报错,因为无论字符串还是数字都有 toString 方法
}

探秘Typescript·TS日常类型

那么,如果我一定要使用substring方法要怎么办呢?聪明的Typescript当然也考虑到了这一点,我们可以用类型窄化来解决这个问题,如:

function fn(key: string | number): void {
  if(typeof key === "string") {
    key.substring(0, 2);// 这个可以正常运行不会报错,因为能进入这个分支,那么key必定是字符串
  }
}

类型别名

我们上面也有为对象、函数等进行类类型描述,然而,上面所使用的类型描述,有可能会比较复杂,也有可能很多地方都要使用,如果向上面那样,哪里定义变量,就在变量后面直接加类型描述,不仅会让代码看起来极为冗长,也不利于类型的复用。因此,Typescript提出了类型别名的概念来解决这个痛点。

type Point = {
	x: number;
  y: number;
}
const point: Point = {
  x: 1,
  y: 2
};
function move(p: Point) {
  // 将任务移动到目标点...
}
function getCurPoint(): Point {
  return {x: 2, y: 3}
}

如上述示例代码,我们发现,将Point类型描述用类型别名的方式抽离出去之后,后面想要使用时,就变得非常方便了。

当然,别名也可以使用联合类型,如:

// React.Key
type Key = string | number;

function fn(key: Key) {
  // ...
}

接口

Typescript中,接口(Interface)的作用其实跟类型别名差不多,比如:

interface Point {
  x: number;
  y: number;
}
const point: Point = {
  x: 1,
  y: 2
};
function move(p: Point) {
  // 将任务移动到目标点...
}
function getCurPoint(): Point {
  return {x: 2, y: 3}
}

上面的示例当中,除了将类型别名换成使用interface定义外,其他都完全一样,程序依然能够正常使用,不会报错。

那么,就有同学会疑问了,既然他们的功能都差不多,为啥要整两个概念出来增加学习和阅读成本呢?

首先,我们来看一下,typeinterface除了上面的相同点之外,还有没有其他的不同点呢?

多类型合并对比

// 类型别名
type Point1 = {x: number; y:number;}
// 接口
interface Point2 {x: number; y: number}

// 别名的聚合实现多类型的合并,聚合后的 Point3 同时拥有了 x、y、z 属性
type Point3 = Point1 & {z: number};
// 接口的继承实现与别名聚合相同的功能
interface Point4 extends Point2 {
  z: number
}

接口的声明合并

除了上述继承方式合并接口外,interface还支持声明合并(Declaration Merging)

interface Point {x: number; y: number}
interface Point {z: number}
// 或许有些同学认为,这样定义,下面的定义会覆盖表上面的定义,导致 Point 只有 z 一个属性,实则不然,在 Typescript 当中,这样声明会将两个同名接口进行合并,最终得到下面的样子👇🏻
interface Point {x: number; y: number; z: number;}
// 是不是感觉跟上面的继承的效果一样了呢?

我们可以把这个能力看成是往接口中添加成员方法/属性的能力。

在我们实际开发的过程中,可能经常会遇到这样的问题:我需要往Window对象上增加一个属性或者方法,但是这个属性或者方法原本Window并没有,如果直接添加,得到的只能是编辑器的报错(即使可以正常运行,但看着就不爽)。那么,此时你就可以利用上这个声明合并的能力,在Window对象上增加自己新增的属性或方法描述,如:

interface Window {
  myName: string,
  showName: (name: string)=>void
}
// 这样,我们 Window 对象上就已经存在了这个方法或属性了,你可以放心使用而不用担心编辑器报错了。

类型断言(assertion)

很多时候,我们获得的数据类型可能并不是那么具体,比如:

const input = document.getElementById('input');// 此时我们获得的 box 类型是 HTMLElement 的

但是,我们获取这个对象,本质上事项要使用input的一些属性或方法,如:value属性,而这个属性并非所有的HTMLElement都有的,此时,我们已经明确知道,我们要获取的元素类型就是input了,但Typescript引擎并不知道(看来还是不够聪明)。因此,我们在这里需要告诉Typescript引擎,我现在这个对象就是HTMLInputElement的。

const input = document.getElementById('input') as HTMLInputElement;
console.log(input.value);

从上面的示例中我们可以看到,我们使用了as关键字告诉Typescript引擎,这个对象就是HTMLInputElement,你不用自己瞎折腾了。这样,我们就可以很方便得使用HTMLInputElement拥有的方法和属性了。

当然,Typescript也不是来者不拒的,他只会接受他觉得说得通的类型断言,那么,哪些情况在他看来是说得通的呢?

  • 父类 as 子类:如上面的示例中HTMLElementHTMLInputElement的父类

  • 联合 as 单个

    type Sport = "football" | "running";
    
    function getSport(sport: Sport) {
      const football = sport as "football";
    	const pingPong = sport as number;// 报错,因为 number 无法转换为 Sport类型
    }
    

由此可以看出,Typescript虽然在自己犯傻的时候允许你给他指出前进的道路,但也会坚守自己的原则与底线的,不能你让他跳火坑也跳吧。

当然,如果你一定要这么搞,也是有办法可以骗过Typescript的,如:

type Sport = "football" | "running";

function getSport(sport: Sport) {
  const football = sport as "football";
	const pingPong = sport as unknown as number;// 不报错了
}

但实际使用的时候,不太推荐这种方式,因为这样就会失去很多Typescript所带来的类型校验了。

字面类型(Literal Type)

上面的示例中有说过,在Typescript中,实际上对于常量的处理是有一些特殊的。在定义常量时,如果我们没有显式得指定一个变量的类型,但给常量提供了初始值的话(必须赋予初始值,因为常量一旦定义和初次赋值之后,不能再被修改,因此,必须在定义常量时便为其赋值),这个变量的类型就是字面类型,既初始值是什么,他的类型就是什么。如:

const age = 8;// 该变量的类型为:数字 8,而非 number
const name = "kiner";// 该变量的类型为:字符串 "kiner",而非 string
const bool = false;// 该变量的类型为:false,而非 boolean
// 需要特别注意的是在定义对象和数组类常量时,并不会直接用字面量约束该对象的属性
// 该变量的类型为:{ name: string; age: number; },而非 { name: "kiner"; age: 8; },这样我们仍可以通过 obj.name="tang"对对象的属性进行修改,但诸如 obj = {name: "tang", age: 8} 这种操作是不被允许的,因为常量一经初始化就不允许修改
const obj = {name: "kiner", age: 8};
// 下面变量的类型为:(string|number)[],而非 [string, number]。因为在定义这个变量后,引擎不确定你是否会通过如 push、splice 等方法改变数组内的元素数量,因此,不能直接用元组类型。所以,如果一个常量时元组,我们必须要显式地定义他的类型
const arr = ["kiner", 8];

但是,上面只是Typescript对于常量类型的理解,假如说我们使用typeof获取变量的运行时类型,该是咋样,还是咋样:

console.log(typeof age);// number
console.log(typeof name);// string
console.log(typeof bool);// boolean
console.log(typeof obj);// object
console.log(typeof arr);// object

字面类型的一个坑

在实际学习和开发过程中,可能经常会遇到类似的问题:

function fetch(url: string, method: "GET" | "POST") {
  // do ...
}
fetch("https://www.163.com", "GET");// 可以正常使用

const params = {url: "https://www.163.com", method: "GET"};
fetch(params.url, params.method);// 报错:类型“string”的参数不能赋给类型“"GET" | "POST"”的参数。

// 我们可以看到,如果我们直接为 fetch 方法指定 method 为 GET 是没有任何问题的,但是,如果如上面 params 这样,虽然定义了一个常量用于保存这些参数,但我们实际上可以通过 params.method = "DELETE" 的方式修改常量对象中的属性值,所以,在 params 中的 method 并不是一个字面量 GET 类型,而是 string 类型,如果我们直接把 string 类型赋值给字面量类型的联合,即 "GET" | "POST",就会得到上述报错。因为 Typescript 认为 string 类型除了可能是 GET 或 POST 外,还可能是其他的任意字符串,不能与字面类型联合匹配。
// 因此,大家在这里要特别注意一下。

// 那么,想上述情况,如果我们就是想这么用要怎么办呢?我们可以使用类型断言
// 1. 调用时断言
fetch(params.url, params.method as "GET");// 正常使用,这里的 method 已经被断言为了 GET 了
// 2. 在定义 params 时断言
const params = {url: "https://www.163.com", method: "GET" as "GET"};
fetch(params.url, params.method);// 正常使用
// 3. 将整个 params 断言为常量
// 断言为常量后,整个 params 的所有属性都将变成只读属性,不允许修改,这样就不存在修改 method 属性导致的隐患了,Typescript也是可以接受的
const params = {url: "https://www.163.com", method: "GET"} as const;
fetch(params.url, params.method);// 正常使用

探秘Typescript·TS日常类型

null/undefined

nullundefinedJavascript中的两个基础数据类型(Primitive type),在js当中,在不严格校验类型的情况下,他们都可以看成假值,但他们二者还是有一些区别的:

  • null:是被认为赋予了空值
  • undefined:是一个没有分配值的变量。

Typescript当中有一个配置项:scrictNullChecks,当这个配置被设置为on时,在有可能值为null时,我们需要显式地对这个变量是否为null进行检查,如:

function user(info: string | null): void {
  if(info === null) return;
  console.log(info.substring(0,2));
}

非空断言

在上面null的示例中,因为说你不想写那么多的代码判断是否为空值,因为你100%确定在这种情况下,值肯定是不为空的,那么,我们还可以使用非空断言告诉Typescript这个变量不可能为空的,你就不用瞎操心了。非空断言的符号是:!

function user(info: string | null): void {
  console.log(info.substring(0,2));// 报错,因为 info 可能为 null,但 null 不存在 substring
  console.log(info!.substring(0,2));// 正常运行不会报错
}

枚举类型

枚举类型其实就是我们在显式生活中的一种分类手段,比如说我们把人按照身份分为:学龄前儿童、学生、上班族、退休人员等,而在编程世界当中,枚举也是起到的是相同的目的,它是对于拥有一定关联的数据的分类集合,如:

enum HTTPStatus {
  success = 200,
  clientError = 400,
  serverError = 500
}
// 方向的枚举,当枚举值是数字类型时,我们可以只是指定某一个枚举的值,其他值Ts可以按照顺序自行推导
enum Dir {
  up = 1,
  down,
  left,
  right
}
// 我们也可以指定字符串的值
enum status {
  error = 'error',
  success = 'success',
  pedding = 'pedding'
}

反向映射

如果我们想要获取枚举类型的字符串描述,如上面示例中DirUp,我们要如何获取呢?

// 或许有些同学说可以这样获取
console.log(Dir.up);// 这里不能获取到字符串‘up’,而是‘1’
console.log(Dir[Dir.up]);// 这样才能获取到字符串‘up’

// 因此,我们可以利用这个特性用于定义label和value

enum Dir {
  上=1,
  下,
  左,
  右,
}
console.log(Dir.上);// 输出结果为:1
console.log(Dir[Dir.上]);// 输出结果为:上

探秘Typescript·TS日常类型

我们可以利用上面的特性进行遍历映射:


enum Dir {
  上=1,
  下,
  左,
  右,
}

const options = Object.keys(Dir).filter(key => !/\d+/.test(key)).map(key =>({label: key, value: Dir[key as any]}))
console.log(options);

探秘Typescript·TS日常类型

枚举类型在Javascript是不存在的,那么在运行时,我们的枚举会被编译成什么样子呢?

探秘Typescript·TS日常类型

从上图我们可以看到,在运行时,实际上会把枚举类型解析成一个对象,而我们定义的每一个枚举的键和值都是这个对象的属性和值。

原文链接:https://juejin.cn/post/7327469683632373795 作者:kinertang

(0)
上一篇 2024年1月25日 上午10:38
下一篇 2024年1月25日 上午10:48

相关推荐

发表回复

登录后才能评论