说说TS中的Record

Record是 ts 中的一个高级类型,它可以用来构造一个具有给定类型T的一组属性K的类型。它的定义如下:

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

这里,K是一个可以被赋值给keyof any的类型,也就是说,它可以是一个字符串、数字、符号或者联合类型。T是任意类型。[P in K]: T表示将K中的每个属性名映射到类型T上,从而得到一个新的类型。

我们来看几个栗子 🌰:

// 将一个枚举类型的值映射到另一个类型上
enum Color {
  Red,
  Green,
  Blue,
}

type ColorInfo = Record<Color, { name: string; hex: string }>;

const colors: ColorInfo = {
  [Color.Red]: { name: 'red', hex: '#ff0000' },
  [Color.Green]: { name: 'green', hex: '#00ff00' },
  [Color.Blue]: { name: 'blue', hex: '#0000ff' },
};

在上述示例中,使用了枚举类型Color作为Record的第一个参数,表示创建一个以颜色值为属性名的类型。然后使用了一个对象类型作为第二个参数,表示每个颜色都有一个名字和一个十六进制值。最后,指定变量 colors 的类型为 ColorInfo,即可让 colors 应用 ColorInfo 类型约束。

// 将一个联合类型的值映射到另一个类型上
type Animal = 'dog' | 'cat' | 'fish';

type AnimalInfo = Record<Animal, { name: string; age: number }>;

const animals: AnimalInfo = {
  dog: { name: 'dogName', age: 2 },
  cat: { name: 'catName', age: 3 },
  fish: { name: 'fishName', age: 5 },
};

在上述示例中,将联合类型Animal作为第一个参数,表示创建一个以动物名为属性名的类型。然后将一个对象类型作为第二个参数,表示每个动物都有一个名字和一个年龄。

// 将一个对象类型的属性映射到另一个类型上
interface Person {
  id: number;
  name: string;
}

type PersonInfo = Record<keyof Person, string>;

const person: PersonInfo = {
  id: '001',
  name: 'Alice',
};

在上面的这个例子中,使用对象类型Person的键集合作为第一个参数,表示创建一个与Person具有相同属性名的类型。然后使用字符串类型作为第二个参数,表示每个属性都是字符串类型。

可以看到,使用Record可以方便地将一个类型的属性映射到另一个类型上,并创建出新的类型。这在一些场景下非常有用,比如:

  • 在定义一些常量或配置时,可以使用枚举或联合类型作为键,并将对应的值映射到一个对象类型上,保证键的唯一性和值的结构性。
  • 当封装一些通用的函数或方法时,可以使用Record来定义参数或返回值的类型,以提高代码的复用性和可读性。
  • 对一个已有的类型进行扩展或修改时,可以使用Record来覆盖或替换原有的属性类型,从而避免创建新的类型或使用交叉类型。

嗯,Record确实是一个非常强大和实用的工具类型,帮助我们在 ts 中进行更灵活和高效的类型操作。但是,它也有一些局限性,比如:

  • 只能将一个类型的属性映射到另一个类型上,不能根据属性名或属性值进行条件判断或变换。
  • 不能保留原有类型的修饰符,比如 readonlyoptional
  • 不能处理动态的或未知的属性名。

举个例子,假设我们有一个类型 User,它有三个属性 idnameemail,其中 id 是只读的,name 是必选的,email 是可选的,我们想要使用 Record 来创建一个以用户 id 为键,用户信息为值的类型,如下:

interface User {
  readonly id: number;
  name: string;
  email?: string;
}

type UserMap = Record<number, User>;

但是这样做有一个问题,就是 Record 会忽略原有类型的修饰符,它会将所有的属性都变成可读写和必选的,这可能不是我们想要的结果。我们可以使用内置的工具类型来解决这个问题,比如:

interface User {
  readonly id: number;
  name: string;
  email?: string;
}

type UserMap = Record<number, Readonly<Partial<User>>>;

在这个例子中,我们使用了 ReadonlyPartial 来保留原有类型的修饰符,它会将所有的属性都变成只读和可选的,这样就符合我们的预期了。当然,这里我们也可以根据实际情况来调整修饰符的使用,比如:

interface User {
  readonly id: number;
  name: string;
  email?: string;
}

type UserMap = Record<number, Partial<Pick<User, 'name' | 'email'>>>;

在上述示例中,我们使用了 PartialPick 来保留原有类型的部分属性和修饰符,使用 Partial 将所有的属性都变成可选的,使用 Pick 来指定只包含 nameemail 两个属性,这样就可以减少数据冗余了。

与 object 和索引签名的区别

从上面的介绍中可以看出,Record 是用来声明一个对象的结构类型的。在 ts 中,object 和索引签名也可以做类似的事情,那么它们之间有什么区别?

object 是 ts 中的基础类型,它可以用来表示任意的 js 对象,比如:

const obj: object = {
  name: 'Bob',
  age: 30,
};

// 但是使用object类型声明的对象,不能访问其属性
// 因为object类型并不知道对象的结构,所以不能访问其属性
obj.name; // error
obj.age; // error
obj.gender; // error

// 如果要访问对象的属性,需要使用类型断言
(obj as { name: string }).name; // ok
(obj as { age: number }).age; // ok

而索引签名用来表示对象的结构类型,比如:

interface User {
  id: number;
  name: string;
  email: string;
}

const user: User = {
  id: 1,
  name: 'Alice',
  email: '',
};

// 因为索引签名知道对象的结构,所以可以访问其属性
user.id; // ok
user.name; // ok
user.email; // ok

既然如此,Record 和索引签名都是用来声明对象的结构类型的,那我直接都用索引签名一把梭不行吗?比如下面的示例,我用索引签名声明:

// 用Record声明
type UserMap = Record<number, User>;

// 用索引签名达到相同的效果
type UserMap1 = { [id: number]: User };

确实,这两种方式都可以用来声明一个对象的结构类型,但是它们之间还是有一些区别的,比如:

  • Record 可以使用泛型,而 object 和索引签名不行。
  • Record 可以使用内置的工具类型,而 object 和索引签名不行。
  • Record 可以使用联合类型作为键,而 object 和索引签名不行。
  • 等等等等…(还有很多,不一一列举了)。

文字是苍白的,来看一个例子:

type Color = 'red' | 'green' | 'blue';

// 使用 Record 定义类型
type ColorMap1 = Record<Color, number>;

// 使用 object 定义类型
type ColorMap2 = {
  [key in Color]: number;
};

// 使用索引签名定义类型
interface ColorMap3 {
  [key: string]: number;
}

可以看到,使用 Record 的方式是最简洁的,因为它可以一次性定义键和值的类型,并且使用对象字面量语法来创建对象。而使用 object 的方式虽然也可以一次性定义键和值的类型,但需要使用映射类型语法来定义类型,使用起来略微麻烦。使用索引签名定义类型则最为灵活,可以使用任意类型作为键的类型,但需要手动定义键和值的类型,并使用对象字面量语法来创建对象。

另外,如果你需要使用泛型的话,Record 也是唯一的选择,例如:

// 使用 Record 定义类型
type RecordWithGeneric<K extends string, T> = Record<K, T>;

const record1: RecordWithGeneric<'a' | 'b', number> = {
  a: 1,
  b: 2,
};

// 使用索引签名定义类型
interface IndexSignatureWithGeneric<K extends string, T> {
  [key: K]: T; // 错误:索引签名不能使用泛型类型参数 K
}

const indexSignature1: IndexSignatureWithGeneric<'a' | 'b', number> = {
  a: 1,
  b: 2,
};

上面的这里例子比较简单,也说明了它们之间能力的一些区别,如果更加复杂一点,我们还可以实现一些使用 object 或索引签名无法实现的功能。

// 假设有一个类型Student,它有三个属性id、name和score
interface Student {
  id: number;
  name: string;
  score: number;
}

// 想要定义一个类型StudentMap,它是一个以学生id为键,学生信息为值的对象
// 那么,我们可以使用Record来实现这个需求,如下:
type StudentMap = Record<number, Student>;

// 创建一个变量students,它的类型是StudentMap,并且可以访问它的任何属性
const students: StudentMap = {
  1: { id: 1, name: 'Alice', score: 90 },
  2: { id: 2, name: 'Bob', score: 80 },
};

students[1].name; // ok
students[2].score; // ok

// 如果使用object或索引签名来定义StudentMap,就会有一些问题,比如:
type StudentMap2 = object;

const students2: StudentMap2 = {
  1: { id: 1, name: 'Alice', score: 90 },
  2: { id: 2, name: 'Bob', score: 80 },
};

students2[1].name; // error
students2[2].score; // error

// 因为object类型不知道对象具有哪些属性或属性的类型,所以不能访问任何属性

type StudentMap3 = { [index: number]: any };

const students3: StudentMap3 = {
  1: { id: 1, name: 'Alice', score: 90 },
  2: { id: 2, name: 'Bob', score: 80 },
};

students3[1].name; // ok
students3[2].score; // ok

// 这是因为索引签名类型假设对象的属性值都是any类型,所以可以访问任何属性,但是这样会失去类型安全性

type StudentMap4 = { [index: number]: Student };

const students4: StudentMap4 = {
  1: { id: 1, name: 'Alice', score: 90 },
  2: { id: 2, name: 'Bob', score: 80 },
};

students4[1].name; // ok
students4[2].score; // ok

// 这里索引签名类型指定了对象的属性值都是Student类型,所以可以访问Student的属性,但是这样会限制对象的属性名只能是数字

可以看到,使用 Record 可以更灵活和高效地定义对象类型,而使用 object 或索引签名则有一些局限性或缺点。

所以,当我们需要声明一个对象的结构类型时,最好还是使用 Record,这样可以获得更多的灵活性和可扩展性。

总结

Record 是一个非常实用的工具类型,它可以帮助我们在 ts 中进行更灵活和高效的类型操作。但是,它也有一些局限性,比如不能根据属性名或属性值进行条件判断或变换,不能保留原有类型的修饰符,不能处理动态的或未知的属性名等。合理的使用 Record 可以提高代码的复用性和可读性,但是过度的使用 Record 也会导致代码的可维护性变差,所以在使用 Record 时,一定要根据实际情况来选择合适的使用方式。

如果您觉得本文对您有用,欢迎捐赠或留言~
微信支付
支付宝

发表评论

您的电子邮箱地址不会被公开。 必填项已用 * 标注