Сегодня нас ждет очень практичная тема, это индексные сигнатуры (Index Signatures). Мы уже с вами научились описывать объекты с известными полями, но что делать, когда мы заранее не знаем всех ключей? Именно для таких случаев и существует этот мощный инструмент. Давайте разбираться без лишней суеты, но очень основательно.
Зачем нужны индексные сигнатуры?
Представьте себе такую ситуацию: вы пишете функцию, которая обрабатывает данные, приходящие с сервера. Допустим, это настройки пользователя. Вы знаете, что придёт объект, где ключами будут названия настроек (например, theme, language, notifications), а значениями строки или числа. Но беда в том, что вы не знаете полный и точный список всех возможных настроек заранее. Сервер может добавить новую настройку fontSize, а ваше приложение должно уметь с ней работать.
Если мы попытаемся описать такой объект с помощью уже известных нам интерфейсов или алиасов типов, у нас ничего не выйдет.
// Ошибка! У объекта могут быть другие ключи, кроме 'theme' interface UserSettings { theme: string; language: string; } const settings: UserSettings = { theme: 'dark', language: 'ru', fontSize: 16 // TypeScript выдаст ошибку: объектный литерал может определять только известные свойства };
Мы могли бы использовать [key: string]: any, но это слишком слабое ограничение. Мы теряем всю пользу TypeScript, потому что значение может быть чем угодно: строкой, числом, массивом, целым объектом. Это приведет к ошибкам в runtime.
Вот здесь на сцену и выходит индексная сигнатура. Она позволяет нам описать структуру значений объекта, при этом не указывая конкретные имена ключей. Мы говорим TypeScript: «Смотри, у этого объекта могут быть любые ключи (например, строковые), но их значения должны быть вот такого конкретного типа (например, только string или только number)».
Синтаксис индексной сигнатуры
Синтаксис очень похож на описание обычного свойства, но вместо его имени мы используем квадратные скобки []. Внутри скобок мы указываем тип ключа (чаще всего string или number) и затем тип значения.
interface MyInterface { [key: string]: valueType; } // Или с помощью type alias type MyType = { [key: string]: valueType; };
Давайте перепишем наш пример с настройками, используя индексную сигнатуру. Мы знаем, что значением может быть либо строка (для theme, language), либо число (для fontSize). Для этого используем тип-объединение.
interface UserSettings { [key: string]: string | number; } const settings: UserSettings = { theme: 'dark', // OK, string language: 'ru', // OK, string fontSize: 16, // OK, number cacheDuration: 30 // OK, number }; // А вот это уже вызовет ошибку: const invalidSettings: UserSettings = { theme: 'light', isEnabled: true // ❌ Ошибка: Type 'boolean' is not assignable to type 'string | number'. };
Вот видите, как здорово? Мы сохранили гибкость (можем добавлять любые ключи), но не потеряли контроль над типами значений. TypeScript теперь строго следит, чтобы все значения в таком объекте были либо строками, либо числами.
Сочетание известных и неизвестных свойств
Часто бывает так, что некоторые свойства объекта нам всё-таки известны заранее. Например, мы точно знаем, что в нашем объекте с настройками должно быть свойство theme. TypeScript позволяет нам комбинировать обычные свойства и индексную сигнатуру.
Но здесь есть одно очень важное правило: тип известного свойства должен быть совместим с типом, указанным в индексной сигнатуре. Проще говоря, известное свойство должно быть подтипом типа из индексной сигнатуры.
interface UserSettings { theme: string; // Известное свойство language: string; // Еще одно известное свойство [key: string]: string | number; // Индексная сигнатура } const settings: UserSettings = { theme: 'dark', // OK, тип 'string' совместим с 'string | number' language: 'ru', // OK fontSize: 16 // OK };
А вот если мы нарушим это правило, TypeScript сразу нас остановит:
interface UserSettings { theme: string; [key: string]: number; // Ожидаем, что все значения - числа } // Ошибка: Свойство 'theme' типа 'string' не может быть присвоено строковому индексному типу 'number'.
Почему ошибка? Потому что мы в индексной сигнатуре сказали: «Все значения в этом объекте должны быть числами». Но тут же мы объявили свойство theme со строковым типом, которое нарушает это правило. TypeScript не позволяет создавать такие противоречия.
Типы ключей: string и number
В подавляющем большинстве случаев вы будете использовать тип string для ключей. Это логично, потому что ключи объектов в JavaScript, это всегда строки (даже если вы запишите число, оно будет преобразовано в строку).
Однако TypeScript также позволяет использовать тип number. Это особенно полезно для описания массивоподобных структур или объектов, где доступ к элементам осуществляется по числовому индексу.
interface StringArray { [index: number]: string; // Доступ по числовому индексу вернет строку } const myArray: StringArray = { 0: "first", 1: "second", 2: "third" }; const firstItem = myArray[0]; // Тип firstItem - string // Это очень похоже на обычный массив строк! const normalArray: string[] = ["first", "second", "third"]; const firstNormalItem = normalArray[0]; // Тоже string
Хотя в JavaScript myArray[0] и myArray['0'], это одно и то же (ключ ‘0’ является строкой), TypeScript разделяет эти сигнатуры. Но на практике, если у вас есть и строковая индексная сигнатура [key: string]: T и числовая [index: number]: U, то тип U должен быть подтипом T. Это связано с тем, что при обращении по числовому индексу obj[0] JavaScript на самом деле преобразует его в строку obj['0'].
interface Dictionary { [key: string]: string | number; // Базовый тип для всех строковых ключей [index: number]: string; // Числовой индекс должен возвращать подтип (string) } // Это работает, потому что string является подтипом string | number.
Практическое применение индексных сигнатур
Давайте рассмотрим несколько реальных кейсов, где индексные сигнатуры незаменимы.
1. Словари (Maps) и кэши
Одна из самых частых задач, создание словаря для быстрого доступа к данным по ключу.
// Простой кэш для результатов каких-либо вычислений interface CalculationCache { [expression: string]: number; // Ключ - строка (выражение), значение - результат (число) } const cache: CalculationCache = {}; function expensiveCalculation(expression: string): number { // Сначала проверяем кэш if (expression in cache) { console.log('Возвращаем результат из кэша'); return cache[expression]; // TypeScript знает, что здесь число } // Если нет в кэше, вычисляем (очень долгая операция) const result = eval(expression); // Не используйте eval в реальных проектах! Это пример. // Сохраняем в кэш cache[expression] = result; return result; } console.log(expensiveCalculation('10 * 10 + 5')); // Вычисляет и возвращает 105 console.log(expensiveCalculation('10 * 10 + 5')); // Возвращает 105 из кэша
2. Динамические данные (конфиги, стили, переводы)
Часто данные, приходящие извне (от API, из файлов конфигурации), имеют динамическую природу.
// Представьте, что мы получаем с бэкенда объект с переводом для страницы // Мы не знаем всех ключей полей, но знаем, что значения - строки interface TranslationDict { [messageKey: string]: string; } const en: TranslationDict = { welcome: "Welcome to our site!", goodbye: "Goodbye!", // ... сотни других ключей }; const ru: TranslationDict = { welcome: "Добро пожаловать на наш сайт!", goodbye: "До свидания!", }; function getTranslation(dict: TranslationDict, key: string): string { return dict[key] || `[TRANSLATION NOT FOUND: ${key}]`; } console.log(getTranslation(ru, 'welcome')); // "Добро пожаловать на наш сайт!"
3. Работа с данными в стиле Key-Value Stores
Такие базы данных, как Redis или просто объекты-хранилища, идеально ложатся на эту концепцию.
interface UserSessionStore { [sessionId: string]: { // sessionId - это динамическая строка userId: number; username: string; expiresAt: Date; }; } const sessionStore: UserSessionStore = {}; function createSession(sessionId: string, userId: number, username: string) { sessionStore[sessionId] = { userId, username, expiresAt: new Date(Date.now() + 60 * 60 * 1000) // Через час }; } function getSession(sessionId: string) { return sessionStore[sessionId]; }
Ограничения и нюансы
Индексные сигнатуры имеют свои ограничения.
-
Тип ключа может быть только
string,number,symbolили шаблонным литеральным типом. Другие типы не допускаются. -
Только одна сигнатура. В одном интерфейсе можно использовать только одну индексную сигнатуру (для каждого из типов
string,number,symbolможет быть своя, но с оговоркой о совместимости, которую мы обсуждали). -
Проблема с
undefined. Если вы попытаетесь обратиться по ключу, которого нет в объекте, TypeScript все равно вернет вам тип значения из сигнатуры, а неundefined. Это потенциальный источник ошибок.
interface MyDict { [key: string]: string; } const dict: MyDict = { existingKey: "I'm here" }; const value = dict['nonExistingKey']; // TypeScript считает, что тип value - string. console.log(value); // Но на самом деле значение - undefined! // Это может привести к ошибке: console.log(value.toUpperCase()); // Runtime Error: Cannot read properties of undefined
Как с этим бороться? Есть несколько способов:
-
Включить опцию
noUncheckedIndexedAccessвtsconfig.json. Тогда типvalueв примере выше станетstring | undefined, что более безопасно. -
Сузить тип с помощью проверки
inилиhasOwnProperty.
// Способ 1: Проверка перед использованием if ('nonExistingKey' in dict) { // Теперь TypeScript знает, что ключ существует const safeValue = dict['nonExistingKey']; // тип string } // Способ 2: Использование опции noUncheckedIndexedAccess (рекомендуется для новых проектов)
Альтернативы индексным сигнатурам
В некоторых случаях индексные сигнатуры не единственный и не всегда лучший выход.
1. Record<Keys, Type>
TypeScript предоставляет встроенный утилитарный тип Record<K, T>, который является более предсказуемой и строгой альтернативой для создания словарей.
// Record<string, string> аналогичен { [key: string]: string; } type MyDict = Record<string, string>; // Но главная сила Record в том, что он может ограничить ключи конкретным набором type PagePaths = Record<'home' | 'about' | 'contact', string>; // Это эквивалентно: // type PagePaths = { // home: string; // about: string; // contact: string; // }; const paths: PagePaths = { home: '/', about: '/about-us', contact: '/contact-us' // shop: '/shop' // ❌ Ошибка: лишнее свойство };
Выбор между индексной сигнатурой и Record зависит от задачи. Если ключи действительно произвольные и неизвестные, use индексная сигнатура. Если набор ключей известен или его нужно ограничить, use Record<SpecificKeys, Type>.
2. Опциональные свойства
Если объект может иметь много известных свойств, но они все опциональны, иногда лучше просто перечислить их все через ?.
// Не всегда хорошо: interface Config { [key: string]: string; } // Иногда лучше, если большинство свойств все-таки известны: interface BetterConfig { apiUrl?: string; timeout?: number; retryCount?: number; // ... и т.д. // Для действительно неизвестных свойств можно оставить индексную сигнатуру [otherKey: string]: unknown; }
Практические задачи для закрепления
Задача 1: Кэширующий декоратор
Напишите декоратор метода cache, который запоминает результаты вызова метода для одних и тех же аргументов и возвращает их из кэша при повторном вызове. Используйте индексную сигнатуру для описания кэша. Предположим, что все аргументы метода можно однозначно преобразовать в строку.
function cache(target: any, propertyName: string, descriptor: PropertyDescriptor) { // Ваш код здесь const originalMethod = descriptor.value; const cache: { [key: string]: any } = {}; descriptor.value = function (...args: any[]) { // Создаем ключ кэша, соединяя все аргументы в строку const cacheKey = args.join('_'); if (cacheKey in cache) { console.log(`Возвращаем из кэша: ${cacheKey}`); return cache[cacheKey]; } const result = originalMethod.apply(this, args); cache[cacheKey] = result; console.log(`Вычисляем и кэшируем: ${cacheKey}`); return result; }; } class Calculator { @cache expensiveOperation(a: number, b: number): number { // Имитация долгого вычисления return a * b; } } const calc = new Calculator(); console.log(calc.expensiveOperation(2, 3)); // "Вычисляем и кэшируем: 2_3", затем 6 console.log(calc.expensiveOperation(2, 3)); // "Возвращаем из кэша: 2_3", затем 6 console.log(calc.expensiveOperation(4, 5)); // "Вычисляем и кэшируем: 4_5", затем 20
Задача 2: Типизация глобального состояния
Опишите интерфейс для объекта глобального состояния приложения AppState. В состоянии есть обязательное поле currentUser типа { name: string }. Также в состоянии может динамически добавляться любое количество модулей, каждый из которых представляет собой объект с обязательным полем isLoaded: boolean.
interface AppState { currentUser: { name: string }; // Ваш код здесь с индексной сигнатурой [moduleKey: string]: { isLoaded: boolean } | { name: string }; // Это решение неидеально, см. объяснение ниже } // Правильное решение должно учитывать, что currentUser - известное свойство. // Лучше вынести тип для динамических модулей и использовать объединение. // Более правильный вариант: interface AppState { currentUser: { name: string }; [moduleKey: string]: { isLoaded: boolean }; // ❌ Conflict: currentUser is not assignable to { isLoaded: boolean } } // Из-за конфликта типов лучше сделать так: interface AppState { currentUser: { name: string }; // Известные свойства должны быть совместимы с сигнатурой, поэтому используем общий тип [moduleKey: string]: { isLoaded: boolean } | { name: string }; } // Но это не очень безопасно. // ЛУЧШИЙ ВАРИАНТ: использовать intersection (&) type AppState = { currentUser: { name: string }; } & { [moduleKey: string]: { isLoaded: boolean }; }; // Однако при таком подходе обращение к динамическому модулю может потребовать приведения типов. // Эта задача продвинутая и показывает нюансы комбинации свойств.
Задача 3: Функция для слияния двух объектов
Напишите функцию mergeObjects, которая принимает два объекта и возвращает новый объект, являющийся их слиянием (если ключи совпадают, приоритет у второго объекта). Используйте индексные сигнатуры для типизации параметров и результата.
function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U): T & U { // Ваш код здесь return { ...obj1, ...obj2 }; } // Более точная типизация без использования extends object (более сложный вариант): function mergeObjects< T extends { [k: string]: unknown }, U extends { [k: string]: unknown } >(obj1: T, obj2: U): T & U { return { ...obj1, ...obj2 }; } const objA = { a: 1, b: 'hello' }; const objB = { b: 'world', c: true }; const merged = mergeObjects(objA, objB); // merged должен иметь тип { a: number, b: string, c: boolean } console.log(merged); // { a: 1, b: 'world', c: true }
Заключение
Мы научились описывать объекты с динамическими ключами, не жертвуя при этом проверкой типов для их значений. Это критически важный навык для работы с данными, структура которых известна лишь частично, конфиги, переводы, кэши, состояния и многое другое.
Мы также разобрали основные подводные камни (например, проблему с undefined и конфликты с известными свойствами) и познакомились с альтернативами, такими как Record<K, T>. Главное понимать, в каких ситуациях какой инструмент применять.
Попробуйте применить индексные сигнатуры в своем коде, где вы раньше использовали any или просто отключали проверку для объектов с динамическими ключами.
В следующем уроке мы перейдем к еще более продвинутым и интересным темам.
Если ты только присоединился к нам, то найти полный курс с уроками по TypeScript для начинающих можно здесь: https://max-gabov.ru/typescript-dlya-nachinaushih.
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


