Перейти к содержанию

Индексные сигнатуры

К Object в JavaScript (и, следовательно, в TypeScript) можно получить доступ с помощью строки, содержащей ссылку на любой другой JavaScript объект.

Вот краткий пример:

let foo: any = {};
foo['Hello'] = 'World';
console.log(foo['Hello']); // World

Мы храним строку "World" под ключом "Hello". Помните, мы говорили, что он может хранить любой JavaScript объект, поэтому давайте сохраним экземпляр класса, чтобы показать концепцию:

class Foo {
    constructor(public message: string) {}
    log() {
        console.log(this.message);
    }
}

let foo: any = {};
foo['Hello'] = new Foo('World');
foo['Hello'].log(); // World

Помните мы сказали, что к нему можно получить доступ с помощью строки. Если вы передадите в сигнатуру индекса любой другой объект, среда выполнения JavaScript вызовет для него .toString перед получением результата. Это показано ниже:

let obj = {
    toString() {
        console.log('toString вызывается');
        return 'Hello';
    },
};

let foo: any = {};
foo[obj] = 'World'; // toString вызывается
console.log(foo[obj]); // toString вызывается, World
console.log(foo['Hello']); // World

Обратите внимание, что toString будет вызываться всякий раз, когда obj используется в позиции индекса.

Массивы немного отличаются. Для индексации числа виртуальные машины JavaScript будут пытаться оптимизировать (в зависимости от того, действительно ли это массив, совпадают ли структуры хранимых элементов и т.д.). Таким образом, число следует рассматривать как действительный метод доступа к объекту сам по себе (в отличие от строки). Вот простой пример c массивом:

let foo = ['World'];
console.log(foo[0]); // World

Итак, это JavaScript. Теперь давайте посмотрим на изящную обработку этой концепции в TypeScript.

Индексные сигнатуры в TypeScript

Во-первых, поскольку JavaScript неявно вызывает toString для любой сигнатуры индекса в виде объекта, TypeScript выдаст вам ошибку, чтобы новички не стреляли себе в ногу (я вижу, как пользователи стреляют себе в ногу при постоянном использовании JavaScript на stackoverflow):

let obj = {
    toString() {
        return 'Hello';
    },
};

let foo: any = {};

// ОШИБКА: сигнатура индекса должна быть строкой, числом ...
foo[obj] = 'World';

// ИСПРАВЛЕНИЕ: TypeScript заставляет вас быть явным
foo[obj.toString()] = 'World';

Причина для такого принуждения пользователя быть явным в том, что реализация toString по умолчанию для объекта довольно ужасна, например в v8 он всегда возвращает [object Object]:

let obj = { message: 'Hello' };
let foo: any = {};

// ОШИБКА: сигнатура индекса должна быть строкой, числом ...
foo[obj] = 'World';

// Вот где вы на самом деле хранили!
console.log(foo['[object Object]']); // World

Конечно, поддерживается число, потому что

  1. Это необходимо для отличной поддержки массивов / кортежей.
  2. Даже если вы используете его для obj, его реализация toString по умолчанию нормальна (не [object Object]).

Пункт 2 показан ниже:

console.log((1).toString()); // 1
console.log((2).toString()); // 2

Итак, урок 1:

Сигнатуры индекса TypeScript должны быть либо строкой, либо числом.

Краткое примечание: символы также действительны и поддерживаются TypeScript. Но пока не будем туда заходить. Будем двигаться маленькими шажками.

Объявление индексной сигнатуры

Итак, мы использовали any, чтобы сказать TypeScript, что мы можем делать все, что захотим. Фактически мы можем явно указать сигнатуру для index. Например, скажем, вы хотите убедиться, что все, что хранится в объекте с использованием строки, соответствует структуре {message: string}. Это можно сделать с помощью объявления { [index:string] : {message: string} }. Это показано ниже:

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

/**
 * Необходимо хранить в соответствии структуре
 */
/** Ok */
foo['a'] = { message: 'some message' };
/** Ошибка: должно содержать `message` типа string. У вас опечатка в
 * `message` */
foo['a'] = { messages: 'some message' };

/**
 * Тип также проверяется на чтение
 */
/** Ok */
foo['a'].message;
/** Ошибка: messages не существует. У вас опечатка в `message` */
foo['a'].messages;

СОВЕТ: имя сигнатуры индекса, например index в { [index:string] : {message: string} } не имеет значения для TypeScript и предназначен только для удобства чтения. Например если это имена пользователей, вы можете использовать { [username:string] : {message: string} }, чтобы помочь следующему разработчику, который просматривает код (который кстати может оказаться вами).

Конечно, числовые индексы также поддерживаются, например { [count: number] : SomeOtherTypeYouWantToStoreEgRebate }

Все члены должны соответствовать сигнатуре индекса string

Как только у вас есть сигнатура индекса string, все явные элементы также должны соответствовать этой сигнатуре индекса. Это показано ниже:

/** Okay */
interface Foo {
    [key: string]: number;
    x: number;
    y: number;
}
/** Ошибка */
interface Bar {
    [key: string]: number;
    x: number;
    y: string; // ОШИБКА: Свойство `y` должно иметь тип number
}

Это сделано для обеспечения безопасности, чтобы любой доступ к строке давал одинаковый результат:

interface Foo {
    [key: string]: number;
    x: number;
}
let foo: Foo = { x: 1, y: 2 };

// Напрямую
foo['x']; // число

// Опосредственно
let x = 'x';
foo[x]; // число

Использование ограниченного набора строковых литералов

Сигнатура индекса может требовать, чтобы строки индекса были частями объединения литеральных строк, используя замапленные типы, например:

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

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

// Ошибка:
// Тип '{ b: number; c: number; d: number; }' нельзя присвоить типу 'FromIndex'.
// Литерал объекта может указывать только известные свойства, а 'd'
// не существует в типе 'FromIndex'.
const bad: FromIndex = { b: 1, c: 2, d: 3 };

Это часто используется вместе с keyof typeof для захвата типов словаря, описанных далее.

В общем случае определение словаря может быть отложено:

type FromSomeIndex<K extends string> = {
    [key in K]: number;
};

Используйте и строки и числа в качестве индексов

Это не стандартный вариант использования, но компилятор TypeScript, тем не менее, его поддерживает.

Однако у него есть ограничение: индексатор string более строгий, чем индексатор number. Это сделано намеренно, например чтобы разрешить следующий код:

interface ArrStr {
    [key: string]: string | number; // Должен согласовываться со всеми
    // элементами

    [index: number]: string; // Может быть подмножеством индексатора строк

    // Просто пример элемента
    length: number;
}

Шаблон проектирования: вложенная индексная сигнатура

Рекомендации для API при добавлении индексных сигнатур

Довольно часто в сообществе JS можно встретить API, которые неправильно употребляют строковые индексаторы. Например стандартный шаблон для CSS в библиотеках JS:

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

const example: NestedCSS = {
    color: 'red',
    '.subclass': {
        color: 'blue',
    },
};

Постарайтесь не смешивать таким образом индексаторы строк с валидными значениями. Например, опечатка останется невыявленной:

const failsSilently: NestedCSS = {
    colour: 'red', // Ошибок нет, так как colour является допустимым
    // селектором строки
};

Вместо этого поместите вложение в отдельное свойство, например в имя nest (или children, или subnodes и т.д.):

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

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

const failsSilently: NestedCSS = {
    colour: 'red', // Ошибка TS: неизвестное свойство `colour`
};

Исключение определенных свойств из сигнатуры индекса

Иногда вам нужно объединить свойства в сигнатуру индекса. Это не рекомендуется, и вам следует использовать упомянутый выше шаблон вложенной индексной сигнатуры.

Однако, если вы моделируете существующий JavaScript, вы можете обойти это с помощью типа пересечения. Ниже показан пример ошибки, с которой вы столкнетесь без использования пересечения:

type FieldState = {
    value: string;
};

type FormState = {
    isValid: boolean; // Ошибка: не соответствует сигнатуре индекса
    [fieldName: string]: FieldState;
};

Вот обходной путь с использованием типа пересечения:

type FieldState = {
    value: string;
};

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

Обратите внимание, что даже если вы можете объявить его для моделирования существующего JavaScript, вы не можете создать такой объект с помощью 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'];

// Использование его для создания объекта TypeScript не сработает
const bar: FormState = {
    // Ошибка: `isValid` не может быть присвоен `FieldState`
    isValid: false,
};