前言
闲聊:这阵子一直在开水厂实习,所以更新得不那么频繁了,最近快要辞职才闲下心来写写文章,我有个公众号: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 保持一致,比如 string
、number
和 boolean
。在 TS 中,你可以显式地为变量指定类型:
let name: string = "dolphin";
let age: number = 22;
let isSingle: boolean = true;
let u: undefined = undefined; // 表示变量尚未被赋值
let n: null = null; // 显示将值设为空
null 和 undefined
null
和 undefined
可以赋值给任何类型,此时可以理解为它们是所有类型的子类型。如果你在 tsconfig.json
中开启了 strictNullChecks
选项,null
和 undefined
只能赋值给 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
的情况下:
- 只有明确标注为
null
或undefined
的类型(例如string | null
、number | undefined
)才能接收null
或undefined
作为值。 - 其他类型的变量(如纯粹的
string
或number
)不能被赋值为null
或undefined
。
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
使用之前你若不进行类型检查或类型断言就会报错
因此 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
重复定义会报错
这里编辑器报错应该是它的问题,我们不管,最终是可以正常运行的
换成 type
就会真报错
函数类型(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 一致,且无关顺序
数组类型(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')
就会直接报错,就很厉害了~
元组类型(Tuple Type)
元组类型用于表示已知数量和类型的元素的数组。与数组类型不同,元组中的每个元素类型可以不同。
let tuple: [string, number, boolean] = ["hello", 42, true];
元组的长度是固定的,并且每个位置上的类型是确定的。这在需要表示固定结构的数据时非常有用,例如函数返回多个值的情况。当 push 的元素超出时类型必须是已有的,否则报错:
枚举类型(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"
};
Omit:Omit<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"
};
Record:Record<K, T>
构造一个类型,其属性名为 K,属性值为 T
type PersonInfo = Record<string, string>;
const person: PersonInfo = {
name: "dolphin",
email: "dolphin@meituan.com"
};
Exclude:Exclude<T, U>
从类型 T 中剔除可以赋值给 U 的类型
type PersonKeys = "name" | "email" | "age";
type ExcludePerson = Exclude<PersonKeys, "age">;
const person: ExcludePerson = "name"; // 只能是 "name" 或 "email"
Extract:Extract<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" },
};
插一嘴:想要像 node 运行 js 那样用 node 运行 ts,需要安装
ts-node
模块(npm install -g ts-node ),然后就可以 ts-node your-file.ts 运行 ts 脚本了
最后
ts 相关内容还是非常多的,要不然大家为什么调侃为类型体操呢,不过掌握本文的 ts 知识让你上手项目以及阅读源码再或者是应对面试应该是没问题了。
文章中若出现错误内容还请各位大佬见谅。如果有任何问题或建议,欢迎指出,另外,有不懂之处欢迎在评论区留言。如果觉得文章对你的学习有所帮助,还请 关注、点赞、收藏 一键三连,感谢支持!