索引类型(Index types)
TypeScript 中的索引类型(Indexed Types)是一种高级类型,允许我们以更灵活和动态的方式定义和操作类型。 它其实包含三个部分:索引访问,索引签名、索引类型查询。 索引类型允许我们通过索引去访问对象属性的类型,常见的形式有:
- 索引访问类型(Indexed Access Types):
**obj[key]**
- 索引签名(Index Signatures):
**{ [key: Type]: ValueType }**
- 映射类型(Mapped Types):
**{ [P in Keys]: Type }**
索引访问 type[key]
使用索引访问语法(**type[key]**
)可以访问属性的类型。 本质就是通过键名,去访问这个键对应的键值的类型
interface Person {
name: string;
age: number;
}
type PersonNameType = Person['name']; // 'string'
type PersonAgeType = Person['age']; // PersonAgeType类型为 number
console.log(PersonAgeType); // 注意,类型是不能作为值来log的
这种形式在编写类型守卫函数时非常有用:
function ensureString(val: any): val is string {
return typeof val === 'string';
}
// 泛型函数narrow,至少有一个参数是泛型参数T,
function narrow<T>(obj: T, key: string): T[keyof T] {
if (ensureString(obj[key])) {
// 这里 obj[key] 被缩小为 string 类型
return obj[key] as T[keyof T];
}
// ...
throw new Error(`Value at key '${key}' is not a string.`);
}
eg.
interface MyObject {
name: string
age: number
email: string
}
const myObj: MyObject = {
name: 'demo',
age: 18,
email: 'qq.com'
}
function ensureString(value: any): value is string {
return typeof value === 'string'
}
function narrow<T>(obj: T, key: string): string {
if (ensureString(obj[key])) {
return obj[key] as string
}
throw new Error(`Value at key '${key}' is not a string.`)
}
// 使用泛型函数 narrow
const nameTest: string = narrow(myObj, 'name') // 这里 T 将会推断为 MyObject
console.log(nameTest) // 输出: "Alice"
const email: string = narrow(myObj, 'email')
console.log(email) // 输出: "alice@example.com"
const age: number = narrow(myObj, 'age') // 这里会导致类型错误,因为 narrow 函数保证返回的是字符串类型
索引签名
**{ [key: Type]: ValueType }**
索引签名用于定义一个可以通过索引访问的类型。
interface StringArray {
// 索引签名
[index: number]: string;
}
let arr: StringArray = ['a', 'b']; // 正确
arr[0] = 'c'; // 正确
arr[1] = 123; // 错误,期望是 string 类型
interface StringDict {
[key: string]: string;
}
const myDict: StringDict = {
name: 'Alice',
age: '30', // 正确,值可以是任意字符串
};
索引签名还可以用作约束,限制对象属性的类型:
interface ProductDict {
[key: string]: { name: string; price: number };
}
const products: ProductDict = {
// 21 : {} 左边写number也可以,因为键名会默认转为字符串
'p01': { name: 'Banana', price: 1.5 }, // 正确
'p02': { name: 'Apple', count: 2 }, // 错误,缺少 price 属性
};
应用场景
在重构 JavaScript 代码时,为内部属性较多的对象声明一个 any 的索引签名类型,以此来暂时支持对类型未明确属性的访问,并在后续一点点补全类型:
interface AnyTypeHere {
[key: string]: any;
}
const foo: AnyTypeHere['yyds'] = 'any value';
- 定义字典类型
典类型通常用于存储键值对,索引签名可以很好地描述这种结构:
interface StringDict {
[key: string]: string;
}
const myDict: StringDict = {
name: 'Alice',
age: '30',
};
- 描述异构数据结构
interface TupleArray {
[index: number]: number | string;
}
const arr: TupleArray = [1, 'a', 2, 'b'];
- 约束对象属性类型
索引签名还可以用作约束,限制对象属性的类型:
interface ProductDict {
[key: string]: { name: string; price: number };
}
const products: ProductDict = {
'p01': { name: 'Banana', price: 1.5 }, // 正确
'p02': { name: 'Apple', count: 2 }, // 错误,缺少 price 属性
};
- 结合映射类型
索引签名常与映射类型一起使用,为已有类型创建新的类型:
type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Partial<T> = { [P in keyof T]?: T[P] };
索引查询类型 keyof T
**keyof T**
, 索引类型查询操作符。 对于任何类型 T,** keyof T**
的结果为 T上已知的公共属性名的联合。**keyof T**
这种形式会导致一个新的类型。
interface Person {
name: string;
age: number;
}
type PersonKey = keyof Person; // "name" | "age"
type PersonAge = Person['age']; // number
keyof Person
返回一个字符串字面量联合类型 "name" | "age"
Person['age']
返回 Person 接口中 age 属性的类型 number
严谨地说,它可以将对象中的所有键转换为对应字面量类型,然后再组合成联合类型。注意,这里并不会将数字类型的键名转换为字符串类型字面量,而是仍然保持为数字类型字面量。
interface Foo {
yyds: 1,
599: 2
}
type FooKeys = keyof Foo; // "yyds" | 599
// 在 VS Code 中悬浮鼠标只能看到 'keyof Foo'
// 看不到其中的实际值,你可以这么做:
type FooKeys = keyof Foo & {}; // "yyds" | 599
我们可以写段伪代码来模拟 “从键名到联合类型” 的过程。
type FooKeys = Object.keys(Foo).join(" | ");
还可以直接 keyof any 来生产一个联合类型,它会由所有可用作对象键值的类型组成:string | number | symbol
。也就是说,它是由无数字面量类型组成的,由此我们可以知道, keyof 的产物必定是一个联合类型。
应用场景
- 常用于泛型
K 的约束类型是 keyof T
,这确保了只有 T 的属性名才能作为 key 参数传入。
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = { a: 1, b: 2 };
let a = getProperty(x, 'a'); // 返回值类型为 number
let c = getProperty(x, 'm'); // 错误, 'm' 不是 x 的键
- 映射类型
索引查询类型的另一个常见用例是映射类型
,比如内置的 Readonly
和 Partial
工具类型:
type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Partial<T> = { [P in keyof T]?: T[P] };
**通过 keyof 遍历类型 T 的所有键,并映射出新的类型。**
索引类型查询、索引类型访问通常会和映射类型一起搭配使用,前两者负责访问键,而映射类型在其基础上访问键值类型。
映射类型
映射类型是 TypeScript 中最强大的工具之一,它利用了索引签名的语法,能基于已有的类型创建新的类型。 映射类型指的就是一个确切的类型工具。看到映射这个词应该能联想到 JavaScript 中数组的 map 方法,实际上也是如此,映射类型的主要作用即是基于键名映射到键值类型。
内置工具类型如Readonly
、Partial
、Pick
等就是通过映射类型实现的:
type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Partial<T> = { [P in keyof T]?: T[P] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
eg.
type Stringify<T> = {
[K in keyof T]: string;
};
这个工具类型会接受一个对象类型(假设我们只会这么用),使用 keyof 获得这个对象类型的键名组成字面量联合类型,然后通过映射类型(即这里的 in 关键字)将这个联合类型的每一个成员映射出来,并将其键值类型设置为 string。
具体表现是这样
// 将一个接口或对象类型 T 映射为一个新的类型,
// 其中所有属性值的类型都被转换为 string 类型。
type Stringify<T> = {
[K in keyof T]: string;
};
interface Foo {
prop1: string;
prop2: number;
prop3: boolean;
prop4: () => void;
}
type StringifiedFoo = Stringify<Foo>;
// 等价于
interface StringifiedFoo {
prop1: string;
prop2: string;
prop3: string;
prop4: string;
}
伪代码如下
const StringifiedFoo = {};
for (const k of Object.keys(Foo)){
StringifiedFoo[k] = string;
}
我们应该很少会需要把一个接口的所有属性类型映射到 string?这有什么意义吗?别忘了,既然拿到了键,那键值类型其实也能拿到:
type Clone<T> = {
[K in keyof T]: T[K];
};
这里的T[K]
其实就是上面说到的索引类型访问,我们使用键的字面量类型访问到了键值的类型,这里就相当于克隆了一个接口。需要注意的是,这里其实只有K in
属于映射类型的语法,keyof T
属于 keyof 索引查询操作符,[K in keyof T]的[]属于索引签名类型,T[K]属于索引类型访问。
in 操作符
在 TypeScript 中,in 操作符有两个主要的用途:
遍历类型
in 操作符可以用于遍历联合类型或者接口/对象类型的键,并基于键创建映射类型。这是 TypeScript 中一个强大的类型操作能力,被称为"映射类型"。
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
interface Person {
name: string;
age: number;
}
type ReadonlyPerson = Readonly<Person>;
// 等同于
// type ReadonlyPerson = {
// readonly name: string;
// readonly age: number;
// }
[P in keyof T]
遍历了 T
类型的所有键,并将对应的属性映射
为一个新的只读属性。
对象属性检查
in 操作符也可以用于检查某个属性是否存在于对象类型中,返回一个布尔值。
interface Person {
name: string;
age: number;
}
const person: Person = { name: 'Alice', age: 30 };
if ('name' in person) {
// person有name属性
}
if ('gender' in person) {
// person没有gender属性
}