首页菜鸟笔记深入理解typescript中的索引签名Index Signatures
Created At : 2021-11-07
Last Updated: 2022-01-06

深入理解typescript中的索引签名Index Signatures

从vscode的一个报错说起

const AllDevSitesObj = {
  1:[
    {
      id: 1,
      text: 'Web小白入门',
      description: '面向零基础的开发小白的第一个web开发入门教程,从零制作并上线一个静态网站。',
      link: '/tutorials/web/zeroguide/start.html',
    }
  ]
}


for(const item in AllDevSitesObj){
  console.log(AllDevSitesObj[item])
}

以上代码在js的语法没有错误,但是typescript中,却提示类型错误,如下:

image-20211107201713394

这里提到了索引签名?那什么是索引签名呢? 先给出上面的正确写法:

interface ObjType {
  [x:number]: {
    id: number,
    text: string,
    description: string,
    link: string,
  }[]
}
const AllDevSitesObj:ObjType = {
  1:[
    {
      id: 1,
      text: 'Web小白入门',
      description: '面向零基础的开发小白的第一个web开发入门教程,从零制作并上线一个静态网站。',
      link: '/tutorials/web/zeroguide/start.html',
    }
  ]
}


export function getDevSitesItems(pid:number) {
  var DevSiteItems:ObjType[] = []
  for(const item in AllDevSitesObj){
    if(pid===Number(item)){
      DevSiteItems = AllDevSitesObj[pid];
      break;
    }else {
      DevSiteItems = []
    }
  }
  return DevSiteItems;

}

官方文档对索引签名的介绍

一是在对象对象(Object Types)章节:TypeScript: Documentation - Object Types (typescriptlang.org)

二是在类(Classes)中有介绍:TypeScript: Documentation - Classes (typescriptlang.org)

三是在Mapped Types中有介绍:TypeScript: Documentation - Mapped Types (typescriptlang.org)

我们知道:在typescript中,要定义一个对象的类型,有三种方式:

//1. 匿名的字面量对象类型
function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}

//2. 使用接口
interface Person {
  name: string;
  age: number;
}
 
function greet(person: Person) {
  return "Hello " + person.name;
}

//3. 使用类型别名 type
type Person = {
  name: string;
  age: number;
};
 
function greet(person: Person) {
  return "Hello " + person.name;
}

对象的属性可以是只读的,也可以是可选的,如:

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}
interface SomeType {
  readonly prop: string;
}

索引签名定义

有时候你无法提前知道某个类型所有属性的名字,但你知道这些属性值的类型。

在这种情况下,你可以使用索引签名去描述可能值的类型

举个例子:

interface StringArray {
  [index: number]: string;
}
 
const myArray: StringArray = getStringArray();
const secondItem = myArray[1];   //const secondItem: string

面的代码中,StringArray 接口有一个索引签名。

这个索引签名表明当 StringArraynumber 类型的值索引的时候,它将会返回 string 类型的值。

一个索引签名的属性类型要么是 string,要么是 number。当然也可以是symbol类型。或同时支持两种类型

interface StringArray {
    //number类型ok
    [index: number]: string;
}

interface StringArray {
    //string 类型,ok
    [index: string]: string;
}
interface StringArray {
    //symbol类型,ok
    [index: symbol]: string;
}

interface StringArray {
    //OK
    [index: string|number]: string;
}

interface StringArray {
    //Error
    [index: object]: string;
}

⚡ 🐯 但前提是,数值型索引返回的类型必须是字符串型索引返回的类型的一个子类型。

这是因为,**当使用数值索引对象属性的时候,JavaScript 实际上会先把数值转化为字符串。**这意味着使用 100(数值)进行索引与使用 "100"(字符串)进行索引,效果是一样的,因此这两者必须一致。

interface Animal {
   name: string;
}
   
interface Dog extends Animal {
	breed: string;
}
   
//Error
interface NotOkay {
    [x: number]: Animal;
    // 即数值类型返回的Animal类型,不能赋值为Dog类型
    [x: string]: Dog;
}

//ok
//因为Dog是的Animal子类型,注意顺序
interface NotOkay {
    [x: number]: Dog;
    [x: string]: Animal;
}

不过,如果索引签名所描述的类型本身是各个属性类型的联合类型,那么就允许出现不同类型的属性了:

interface NumberOrStringDictionary {
    [index: string]: number | string;
    length: number; // length 是数字,可以
    name: string; // name 是字符串,可以
}

可以设置索引签名是只读的,这样可以防止对应索引的属性被重新赋值:

interface ReadonlyStringArray {
    [index: number]: string;
}
   
let myArray: ReadonlyStringArray = {
    1: 'hello'
};
console.log(myArray[1]) //hello
myArray[1] = "Mallory";

console.log(myArray[1])  //Mallory

//但是如果设置只读,则无法修改
interface ReadonlyStringArray {
   readonly  [index: number]: string;
}
   
let myArray: ReadonlyStringArray = {
    1: 'hello'
};
//Error: 类型“ReadonlyStringArray”中的索引签名仅允许读取。
myArray[1] = "Mallory";

类索引签名

类可以声明索引签名,其工作方式和其它对象类型的索引签名一样:

class MyClass {
    [s: string]: boolean | ((s: string) => boolean);
    check(s: string) {
        return this[s] as boolean;
    }
}

const c = new MyClass()
c.hello = true;
console.log(c.check('hello'))  //true

因为索引签名类型也需要捕获方法的类型,所以要有效地使用这些类型并不容易。

通常情况下,最好将索引数据存储在另一个位置,而不是类实例本身。

映射类型Mapped Types

有时候我们不想重复编写代码,这时候就需要基于某个类型创建出另一个类型。

索引签名用于为那些没有提前声明的属性去声明类型,而映射类型是基于索引签名的语法构建的。

type OnlyBoolsAndHorses = {
    [key: string]: boolean | Horse;
};
const conforms: OnlyBoolsAndHorses = {    
    del: true,
    rodney: false,
};

映射类型也是一种泛型类型,它使用 PropertyKey(属性键)的联合类型(通常通过 keyof 创建)去遍历所有的键,从而创建一个新的类型:

type OptionsFlags<Type> = {
    [Property in keyof Type]: boolean;
};

在这个例子中,OptionsFlags 会接受类型 Type 的所有属性,并将它们的值改为布尔值。

type OptionsFlags<Type> = {
    [Property in keyof Type]: boolean;
}

type FeatureFlags = {
    darkMode: () => void;
    newUserProfile: () => void;
};

type FeatureOptions = OptionsFlags<FeatureFlags>;

//测试
const test: FeatureOptions = {
    darkMode: true,
    newUserProfile: true
}

深入理解索引签名

JavaScript 在一个对象类型的索引签名上会隐式调用 toString 方法。

而在 TypeScript 中,为防止初学者砸伤自己的脚(我总是看到 stackoverflow 上有很多 JavaScript 使用者都会这样。),它将会抛出一个错误。

let obj = {
    toString() {
      console.log('toString called');
      return 'Hello';
    }
};

let foo: any = {};
foo[obj] = 'World'; // toString called
console.log(foo[obj]); // toString called, World

上述代码在JavaScript中,正常使用,但是在Typescript中却会抛出错误。

const obj = {
    toString() {
      return 'Hello';
    }
  };
  
const foo: any = {};

// ERROR: 索引签名必须为 string, number....
foo[obj] = 'World';

// FIX: TypeScript 强制你必须明确这么做:
foo[obj.toString()] = 'World';

定义一个索引签名

假设你想确认存储在对象中任何内容都符合 { message: string } 的结构,你可以通过 [index: string]: { message: string } 来实现。

const foo: {
  [index: string]: { message: string };
} = {};

// 储存的东西必须符合结构
// ok
foo['a'] = { message: 'some message' };

// Error, 必须包含 `message`
foo['a'] = { messages: 'some message' };

// 读取时,也会有类型检查
// ok
foo['a'].message;

// Error: messages 不存在
foo['a'].messages;

索引签名的名称(如:{ [index: string]: { message: string } } 里的 index )除了可读性外,并没有任何意义。例如:如果有一个用户名,你可以使用 { username: string}: { message: string },这有利于下一个开发者理解你的代码。

所有成员都必须符合字符串的索引签名

当你声明一个索引签名时,所有明确的成员都必须符合索引签名:

// ok
interface Foo {
    [key: string]: number;
    x: number;
    y: number;
}
  
// Error
interface Bar {
    [key: string]: number;
    x: number;
    y: string; // Error: y 属性必须为 number 类型
}

使用一组有限的字符串字面量

一个索引签名可以通过映射类型来使索引字符串为联合类型中的一员,如下所示:

type Index = 'a' | 'b' | 'c';
type FromIndex = { [k in Index]?: number };

const good: FromIndex = { b: 1, c: 2 };

// Error:
// `{ b: 1, c: 2, d: 3 }` 不能分配给 'FromIndex'
// 对象字面量只能指定已知类型,'d' 不存在 'FromIndex' 类型上
const bad: FromIndex = { b: 1, c: 2, d: 3 };

索引签名的嵌套

interface NestedCSS {
    color?: string;
    nest?: {
        [selector: string]: NestedCSS;
    };
}

//OK
const example: NestedCSS = {
    color: 'red',
    nest: {
        '.subclass': {
            color: 'blue'
        }
    }
}

//Error
const failsSliently: NestedCSS = {
    colour: 'red'  // TS Error: 未知属性 'colour'
}

索引签名中排除某些属性

有时,你需要把属性合并至索引签名(虽然我们并不建议这么做,你应该使用上文中提到的嵌套索引签名的形式),如下例子:

type FieldState = {
    value: string;
};
  
type FromState = {
    // Error: 不符合索引签名
    //类型“boolean”的属性“isValid”不能赋给“string”索引类型“FieldState”。
    isValid: boolean;
    [filedName: string]: FieldState;
};

TypeScript 会报错,因为添加的索引签名,并不兼容它原有的类型,使用交叉类型可以解决上述问题:

type FieldState = {
  value: string;
};

type FormState = { isValid: boolean } & { [fieldName: string]: FieldState };

请注意尽管你可以声明它至一个已存在的 TypeScript 类型上,但是你不能创建如下的对象:

type FieldState = {
    value: string;
};

type FormState = { isValid: boolean } & { [fieldName: string]: FieldState };
// 将它用于从某些地方获取的 JavaScript 对象
declare const foo: FormState;

const isValidBool = foo.isValid;
const somethingFieldState = foo['something'];

// 使用它来创建一个对象时,将不会工作
const bar: FormState = {
    // 'isValid' 不能赋值给 'FieldState'
    //属性“isValid”与索引签名不兼容。
    //不能将类型“boolean”分配给类型“FieldState”。
    isValid: false
};

索引签名与 Record<Keys, Type> 对比

TypeScript有一个实用类型 Record<Keys, Type>,类似于索引签名。

const object1: Record<string, string> = { prop: 'Value' }; // OK
const object2: { [key: string]: string } = { prop: 'Value' }; // OK

那问题来了…什么时候使用 Record<Keys, Type>,什么时候使用索引签名

乍一看,它们看起来很相似

我们知道,索引签名只接受 stringnumbersymbol 作为键类型。

如果你试图在索引签名中使用,例如,字符串字面类型的联合作为键,这是一个错误。

但是我们可以使用字符串字面值的联合来描述 Record<keys, Type>中的键:

// 使用字符串字面量
type Salary = Record<'yearlySalary'|'yearlyBonus', number>
// OK 
const salary1: Salary = { 
  'yearlySalary': 120_000,
  'yearlyBonus': 10_000
}; 

建议使用索引签名来注释通用对象,例如,键是字符串类型。

但是,当你事先知道键的时候,使用Record<Keys, Type>来注释特定的对象,例如字符串字面量'prop1' | 'prop2'被用于键值。

参考链接

TypeScript: Documentation - Object Types (typescriptlang.org)

TypeScript: Documentation - Classes (typescriptlang.org)

TypeScript: Documentation - Mapped Types (typescriptlang.org)

索引签名 | 深入理解 TypeScript (jkchao.github.io)

我对TypeScript 索引签名的理解-码云笔记 (mybj123.com)