10 分钟掌握 ts 的类型体操

4,289 阅读9分钟

前言

闲聊:这阵子一直在开水厂实习,所以更新得不那么频繁了,最近快要辞职才闲下心来写写文章,我有个公众号:Dolphin_Fung,觉得文章还不错,还希望各路朋友前来关注

实习归来,发现现在公司的项目基本上都是 ts,几乎看不到 js 的项目了,还有一些准备看框架源码的伙伴,源码里面都是 ts 代码。因此 ts 系统性学习还是很有必要的,可能很多人跟我一样比较半吊子,ts 可以看得懂一点,但是又没有了解很多,本期文章我们就来过一遍 ts 类型体操

简单来讲,TypeScript(TS)就是为 JS 提供了一个类型限定,它的主要目的是让 js 代码更加健壮和可维护,在学习 TS 时,最重要的一环就是理解并熟练运用类型系统。而“类型体操”则是通过各种 TS 类型操作技巧,来实现复杂的类型转换和推导,从而充分利用 TS 的类型系统,大家取名的体操这个词还是非常形象的,说明是可以玩出花来的

本篇文章将会入门 TypeScript 的类型体操,讲之前想聊聊 ts 的作用,明确学习后的收益在哪儿

ts 给 js 带了什么?

一、关于类型

ts 最大特性就是为 js 添加了类型检查系统以及类型规范,能在编译阶段提前将类型 bug 暴露出来,而非代码运行时,包括 ts 一些新的关键字,花里胡哨的东西最终目的都是在于此处

let num: number = "hello"; 
// Error: Type 'string' is not assignable to type 'number'

二、关于工程化

只要能促进团队开发效率的事务都可以被称之为工程化,在大型项目中,ts 很好的帮助我们约束了数据类型,让团队开发者遵循同一套代码规范,ts 通过类型检查确保代码质量,使得在持续集成(Continuous Integration, CI)持续交付/部署(Continuous Delivery/Deployment, CD) 过程中更容易发现问题,从而减少上线后的故障风险

和 ts 类似的工具还有个 flow,他是 facebook 开发的一个工具,在使用率和社区活跃度上不如 ts。尽管 ts 风评并没有很好,但在 js 类型约束上仍是最优解

类型体操

基本类型

和 JS 保持一致,比如 stringnumberboolean。在 TS 中,你可以显式地为变量指定类型:

let name: string = "dolphin";
let age: number = 22;
let isSingle: boolean = true;
let u: undefined = undefined; // 表示变量尚未被赋值
let n: null = null; // 显示将值设为空
null 和 undefined

nullundefined 可以赋值给任何类型,此时可以理解为它们是所有类型的子类型。如果你在 tsconfig.json 中开启了 strictNullChecks 选项,nullundefined 只能赋值给 void 或它们各自的类型。

默认未开启 strictNullChecks 选项

let name: string = "John";
name = null;        // 没有报错
name = undefined;   // 也没有报错

开启了 strictNullChecks 选项

let name: string = "John";
name = null;        // 报错:不能将 null 赋值给 string 类型
name = undefined;   // 报错:不能将 undefined 赋值给 string 类型

let nullableName: string | null = "John";
nullableName = null;  // 这样就可以,因为类型包含了 null

在启用了 strictNullChecks 的情况下:

  • 只有明确标注为 nullundefined 的类型(例如 string | nullnumber | undefined)才能接收 nullundefined 作为值。
  • 其他类型的变量(如纯粹的 stringnumber)不能被赋值为 nullundefined
void 类型

void 类型通常用于函数没有返回值的情况,它表示没有任何类型

function logMessage(message: string): void {
  console.log(message);
}

any 类型

any 类型表示任意类型。使用 any 类型的变量可以赋值为任何类型的值,且不会进行类型检查。这在需要兼容动态类型的代码时有用,但滥用 any 会导致类型安全性丧失。

用了这玩意儿不就是等于 js 吗,有些公司的 JD(Job Description) 甚至特意强调可以无 any 流畅书写 ts

let something: any = "hello";
something = 42; // 没有类型错误

unknown 类型

unknown 类型是一个更安全的 any 类型。与 any 不同,在将 unknown 类型赋值给其他类型之前,必须先进行类型检查或类型断言

function processValue(value: unknown) {
  if (typeof value === "string") {
    console.log(`String value: ${value}`);
  } 
  else if (typeof value === "number") {
    console.log(`Number value: ${value}`);
  } 
  else if (typeof value === "object" && value !== null) {
    console.log("Object value:", value);
  } 
  else {
    console.log("Unknown type");
  }
}

processValue("Hello, world!"); // 输出: String value: Hello, world!
processValue(42);              // 输出: Number value: 42
processValue({ id: 1 });       // 输出: Object value: { id: 1 }
processValue(true);            // 输出: Unknown type

使用之前你若不进行类型检查或类型断言就会报错

image.png

因此 unknown 提供了比 any 更好的类型安全保障。

as 类型断言

上面的🌰,只是类型检查,就是直接判断而已,这里用 as 关键字看看,这个就是类型断言,跟名字一样,非常优雅

let value: unknown = "Hello, TypeScript";

// 使用类型断言将 unknown 类型的值转换为 string 类型
let strValue: string = value as string;

console.log(strValue.toUpperCase()); // 输出: HELLO, TYPESCRIPT

never 类型

never 类型表示永远不会有值的类型。通常用于表示那些总是会抛出错误不会有返回值的函数。因此被赋值会报错,哪怕是 any

function error(message: string): never {
  throw new Error(message);
}

never 也可以表示不可能的类型,例如当类型联合被完全穷尽时:

type Animal = "cat" | "dog" | "dolphin";

function handleAnimal(animal: Animal) {
  switch (animal) {
    case "cat":
      console.log("It's a cat.");
      break;
    case "dog":
      console.log("It's a dog.");
      break;
    case "dolphin":
      console.log("It's a dolphin.");
      break;
    default:
      // 这里的 never 类型确保我们已经处理了所有可能的情况
      const exhaustiveCheck: never = animal;
      throw new Error(`Unhandled case: ${exhaustiveCheck}`);
  }
}

接口和类型别名

在 TS 中,接口(interface)和类型别名(type alias)用于定义复杂的类型结构

接口:用于定义对象的形状,用关键字 interface ,很像是构造函数,首字母记得大写,接口是可以玩出花来的,这里仅介绍简单用法

interface Person {
  name: string;
  age: number;
  gender?: string; // 可选属性
}

const person: Person = {
  name: "Dolphin",
  age: 22,
};

接口就是形状,若不论可选属性,那么少一个、多一个属性都会报错,默认就是 Required

类型别名:为类型创建一个新的名称,用关键字 type

// ID 是一个类型别名,它表示 number 类型。
type User = {
  id: number;
  name: string;
};

let user: User = {
  id: 1,
  name: "Dolphin"
};

// User 是一个对象类型的别名,它包含 id 和 name 两个属性
type User = {
  id: number;
  name: string;
};

let user: User = {
  id: 1,
  name: "Dolphin"
};

接口和别名基本上可以互换使用,非常相似。有一点不同的是 interface 重复定义时默认相当于新增属性,而 type 重复定义会报错

这里编辑器报错应该是它的问题,我们不管,最终是可以正常运行的

image.png

换成 type 就会真报错

image.png

函数类型(Function Type)

函数类型用于定义函数的参数类型和返回类型。

function add(x: number, y: number, z?:number): number {
  return x + y;
}

// 函数类型表达
let addFunc: (a: number, b: number) => number;
addFunc = add;

(a: number, b: number) => number 意思是接受两个 number 参数并返回 number 类型值的函数,要是函数没有 return,就相当于返回 void 类型。参数若是可选,用 ? 表示,这里一定要注意可选参数都是放参数最后的,否则报错。默认参数还是用 = ,和 js 一致,且无关顺序

image.png

数组类型(Array Type)

数组类型用于定义一组相同类型的元素,不相同就是接下来的元组类型

let numbers: number[] = [1, 2, 3, 4]; // ‘1’ 也会报错
let strings: string[] = ["hello", "world"];

或者可以使用泛型语法定义数组:

<> 符号就表示泛型

let numbers: Array<number> = [1, 2, 3, 4];

数组中的每个元素必须与数组的类型一致,否则会出现类型错误,这个错误甚至可以在你 push 时也会生效,当你定义数字类型的数组,push('str')就会直接报错,就很厉害了~

image.png

元组类型(Tuple Type)

元组类型用于表示已知数量和类型的元素的数组。与数组类型不同,元组中的每个元素类型可以不同。

let tuple: [string, number, boolean] = ["hello", 42, true];

元组的长度是固定的,并且每个位置上的类型是确定的。这在需要表示固定结构的数据时非常有用,例如函数返回多个值的情况。当 push 的元素超出时类型必须是已有的,否则报错:

image.png

枚举类型(Enum Type)

枚举类型是用于定义一组命名常量的方式,可以用于表示一组相关的值。

enum Direction {
  Up,
  Down,
  Left,
  Right
}

let dir: Direction = Direction.Up;
console.log(Direction.Up, Direction.Down, Direction.Left, Direction.Right); // 0, 1, 2, 3
console.log(Direction[0], Direction[1], Direction[2], Direction[3]); // Up Down Left Right

枚举的值可以是数字或字符串,默认情况下,从 0 开始自动递增。也可以自定义枚举的值:

enum StatusCode {
  Success = 200,
  NotFound = 404,
  ServerError = 500
}

索引类型(Index Type)

索引类型用于表示对象中某些属性的类型,可以通过 keyof 操作符获取对象的所有键,并使用索引访问符 [] 获取键对应的类型。

interface Person {
  name: string;
  age: number;
}

type PersonKeys = keyof Person; // "name" | "age"
type NameType = Person["name"]; // string

keyof 提取对象类型的键,索引访问符获取对象某个键对应的类型。

递归类型(Recursive Type)

递归类型指的是一个类型定义中引用了自身的类型定义,通常用于定义树形结构或嵌套结构。

interface TreeNode {
  value: string;
  children?: TreeNode[];
}

let node: TreeNode = {
  value: "root",
  children: [
    { value: "child1" },
    { value: "child2", children: [{ value: "grandchild1" }] }
  ]
};

TreeNode 类型定义了一个树形结构,其中 children 可以是其他 TreeNode

联合类型与交叉类型

联合类型(Union Types):允许一个变量是多种类型中的一种。操作符或 |

let value: string | number;
value = "hello";
value = 42;

交叉类型(Intersection Types):将多个类型合并为一个类型,有点像是类 class 的 继承extends 。操作符并 &

interface A {
  a: string;
}

interface B {
  b: number;
}

type AB = A & B;

const ab: AB = {
  a: "hello",
  b: 42,
};

类型推导与类型守卫

TS 具有强大的类型推导功能,通常可以根据代码上下文自动推导出类型。

let message = "Hello, World!"; // TS 会推导出 message 是 string 类型

类型守卫(Type Guards)用于在代码中“守卫”某种特定类型,这东西作用不大

function printId(id: string | number) {
  if (typeof id === "string") {
    console.log(`ID: ${id.toUpperCase()}`);
  } else {
    console.log(`ID: ${id}`);
  }
}

条件类型

条件类型(Conditional Types)是类型体操的核心,它允许根据条件返回不同的类型。

type IsString<T> = T extends string ? true : false;

type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false

在上面的例子中,IsString 是一个条件类型,它检查类型 T 是否是 string,如果是,则返回 true,否则返回 false

在这里 extends 就是继承的意思,既然需要可以继承那就一定是同一类型,这里就相当于是 T 是否为 string 类型。接口 用到 extends 继承其实就是就是联合类型

泛型与类型参数

泛型(Generics)使得类型可以参数化,允许编写可重用且灵活的代码。泛型的语法是 <> 中写类型参数,通常用 T 表示,理解为函数参数即可

function identity<T>(arg: T): T {
  return arg;
}

let output1 = identity<string>("myString"); // T 被推断为 string
let output2 = identity<number>(42);         // T 被推断为 number

在泛型中,你可以结合条件类型来进行复杂的类型推导。

工具类型

TS 提供了一些内置的工具类型(Utility Types),可以简化类型的操作。

Readonly:将对象的所有属性变为只读属性。
interface Person {
  id: number;
  name: string;
  email: string;
}

const user: Readonly<Person> = {
  id: 1,
  name: "Dolphin",
  email: "dolphin@meituan.com"
};

// 尝试修改只读属性会导致编译错误
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.
// user.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property.
Partial:将类型的所有属性变为可选。
interface User {
  id: number;
  name: string;
  age: number;
}

type PartialUser = Partial<User>;

const user: PartialUser = { name: "Dolphin" }; // 只有 name 也是合法的
Pick:从类型中挑选部分属性。
interface User {
  id: number;
  name: string;
  age: number;
}

type UserIdAndName = Pick<User, "id" | "name">; // id 和 name 都是必需的

const user: UserIdAndName = { id: 1, name: "Dolphin" };
Required:将类型的所有的属性变为必需

其实接口中的属性,若是没用 ? 可选符号,默认就是必需的,required 的作用就是把 ? 去掉

interface Person {
  name?: string;
  email?: string;
}

type RequiredPerson = Required<Person>;


const person: RequiredPerson = {
  name: "dolphin",
  email: "dolphin@meituan.com"
};
OmitOmit<T, K>从类型 T 中剔除 K 属性。
interface Person {
  name: string;
  email: string;
  age: number;
}

type OmitPerson = Omit<Person, "age">;


const person: OmitPerson = {
  name: "dolphin",
  email: "dolphin@meituan.com"
};
RecordRecord<K, T> 构造一个类型,其属性名为 K,属性值为 T
type PersonInfo = Record<string, string>;


const person: PersonInfo = {
  name: "dolphin",
  email: "dolphin@meituan.com"
};
ExcludeExclude<T, U> 从类型 T 中剔除可以赋值给 U 的类型
type PersonKeys = "name" | "email" | "age";
type ExcludePerson = Exclude<PersonKeys, "age">;


const person: ExcludePerson = "name"; // 只能是 "name" 或 "email"
ExtractExtract<T, U> 从类型 T 中提取可以赋值给 U 的类型
type PersonKeys = "name" | "email" | "age";
type ExtractPerson = Extract<PersonKeys, "name" | "email">;


const person: ExtractPerson = "name"; // 只能是 "name" 或 "email"

哥们儿上半年面 b 站时被考过这几个工具类型😡

映射类型(Mapped Type)

映射类型用于基于一个现有类型创建一个新类型,通常通过对现有类型的所有属性应用一个变换。这个 in 关键字直接理解为 for ··· in 遍历对象的那个 in 即可,keyof 不就是那个索引类型的关键字吗,用于拿到 key

interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = {
  readonly [P in keyof Person]: Person[P];
};

在这个例子中,ReadonlyPerson 是通过将 Person 类型的所有属性变为只读属性创建的。

type PartialPerson = Partial<Person>; // 所有属性变为可选
type ReadonlyPerson = Readonly<Person>; // 所有属性变为只读

面试官:手写 Readonly, Partial, Pick

// 手写 Readonly

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

// 手写 Partial

type MyPartial<T> = {
    [P in keyof T]?: T[P]
}

// 手写 Pick

type MyPick<T, K extends keyof T> = {
    [P in K]: T[P];
};

前面两个很好理解,写了跟没写一样(bushi,这里重点看下 Pick

pick 的第二个参数 K 就是个来自 T 所有属性字面量的联合类型或者单个属性键,keyof T 获取 T 的所有属性键,返回一个联合类型,若 T 是 { name: string; age: number; },那么 keyof T 就是 'name' | 'age'K extends keyof T 是一个泛型约束,表示 T 必须是 keyof T 的子类型,也就是 'name' | 'age' 的子类型。

来个🌰

现在我们来一个稍微复杂一点的例子

假设你有一个对象数组,需要将其转化为对象类型,其键为数组中的某个属性值,值为整个对象。比如,有如下数组:

const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
];

目标是将其转换为如下类型:

type UsersMap = {
  1: { id: 1, name: "Alice" };
  2: { id: 2, name: "Bob" };
};

如下:

type User = { id: number; name: string };

type UsersArray = User[];

type UsersMap<T extends UsersArray> = {
  [K in T[number]['id']]: Extract<T[number], { id: K }>
};

const usersMap: UsersMap<typeof users> = {
  1: { id: 1, name: "Alice" },
  2: { id: 2, name: "Bob" },
};

image.png

插一嘴:想要像 node 运行 js 那样用 node 运行 ts,需要安装 ts-node 模块(npm install -g ts-node ),然后就可以 ts-node your-file.ts 运行 ts 脚本了

最后

ts 相关内容还是非常多的,要不然大家为什么调侃为类型体操呢,不过掌握本文的 ts 知识让你上手项目以及阅读源码再或者是应对面试应该是没问题了。

文章中若出现错误内容还请各位大佬见谅。如果有任何问题或建议,欢迎指出,另外,有不懂之处欢迎在评论区留言。如果觉得文章对你的学习有所帮助,还请 关注、点赞、收藏 一键三连,感谢支持!