Skip to main content

Індекси підписів та типи доступу до індексів у TypeScript

Індексна сигнатура (index signature) описує тип для об'єктів з довільними рядковими або числовими ключами. Тип доступу за індексом (T[K]) читає тип конкретної властивості з наявного типу на рівні компілятора. Це два різні інструменти для двох різних задач.

Теорія

TL;DR

  • Індексна сигнатура ([key: string]: T) = контракт на структуру об'єкта з довільними ключами
  • Тип доступу за індексом (T["key"]) = зчитування типу властивості з іншого типу
  • Аналогія: сигнатура - це правило готелю (будь-яка картка відкриває номер одного класу); тип доступу - це читання категорії номера з плану поверху
  • Сигнатура впливає на те, що можна записати в рантаймі; тип доступу не генерує жодного JS-коду
  • Record<string, T> і [key: string]: T структурно ідентичні; Record коротший і зрозуміліший

Короткий приклад

typescript
// Індексна сигнатура: будь-який рядковий ключ відображається на рядок interface StringDict { [key: string]: string; } const dict: StringDict = { a: "apple" }; dict["b"] = "banana"; // OK // Тип доступу за індексом: зчитує тип ключа "a" type Fruit = { a: "apple"; b: "banana" }; type AppleType = Fruit["a"]; // "apple" type AllValues = Fruit[keyof Fruit]; // "apple" | "banana"

Сигнатури дозволяють записувати динамічні ключі в рантаймі. Типи доступу розв'язуються під час перевірки типів і повністю зникають зі скомпільованого JavaScript.

Головна різниця

Індексна сигнатура визначає контракт: кожне значення, збережене під будь-яким ключем, повинно відповідати задекларованому типу, і TypeScript перевіряє це під час присвоєння. Тип доступу за індексом нічого не робить у рантаймі. User["email"] - це операція на рівні типів, яка зводиться до типу поля email в User. Їх можна ланцюжити: User["address"]["city"] заглиблюється на два рівні у вкладений тип без жодного звернення до значення в рантаймі.

Коли що використовувати

  • Конфігураційні об'єкти з ключами, які задає користувач: [key: string]: unknown
  • Словники рядок-значення: Record<string, T> (читабельніше за сиру сигнатуру)
  • Вилучення типу властивості для обобщеної функції: T[K]
  • Отримання всіх типів значень з інтерфейсу: T[keyof T]
  • Типізація елементів масиву as const: typeof arr[number]
  • Об'єкти тільки з відомими властивостями: сигнатури не потрібні взагалі

Як це обробляє компілятор

TypeScript застосовує перевірку зайвих властивостей до свіжих об'єктних літералів. Якщо присвоїти { a: 1, b: "oops" } типу [key: string]: number, компілятор відразу видасть помилку, бо "oops" не є числом. Після присвоєння через змінну (структурна перевірка) TypeScript розширює невідомі ключі до типу сигнатури без скарг. Типи доступу розв'язуються через type mapper: T[K] підставляється під час constraint solving без генерації коду. Обидві конструкції повністю стираються в JS-виводі.

З практики: якщо поєднуєш відомі властивості зі строковою індексною сигнатурою, всі явні поля мусять задовольняти тип сигнатури. Це часто призводить до розширення до string | number | undefined, тому команди зазвичай використовують Record для чистих словників, а окремі інтерфейси для структурованих даних.

Типові помилки

Відомі властивості конфліктують з індексною сигнатурою

typescript
interface Config { name: string; version: number; // Помилка: number не присвоюється до string [key: string]: string; }

Кожна явна властивість повинна задовольняти сигнатуру. Рішення - розширити тип:

typescript
interface Config { name: string; version: number; [key: string]: string | number; // тепер сумісно }

Використання T[string] замість T[keyof T]

typescript
type Obj = { a: 1; b: 2 }; type Bad = Obj[string]; // Помилка: 'string' не є допустимим типом індексу type Good = Obj[keyof Obj]; // 1 | 2

string занадто широкий. Використовуй keyof Obj або конкретний union літералів.

Запис у readonly сигнатуру

typescript
interface ReadDict { readonly [key: string]: string; } const d: ReadDict = { a: "ok" }; d["b"] = "no"; // Помилка: індексна сигнатура тільки для читання

Прибери readonly, якщо потрібна мутація. Залишай лише якщо незмінюваність задумана навмисно.

Забуваєш as const при доступі до елементів масиву

typescript
const roles = ["admin", "editor"]; // string[] type Role = typeof roles[number]; // string (занадто широко) const rolesConst = ["admin", "editor"] as const; type RoleConst = typeof rolesConst[number]; // "admin" | "editor"

Без as const TypeScript виводить string[], і тип доступу повертає просто string замість union літералів.

Де зустрічається в реальному коді

  • React: React.CSSProperties[keyof React.CSSProperties] дає всі можливі типи значень стилів
  • Express: параметри маршруту типізовані як Record<string, string> в req.params
  • Redux: Action["payload"] вилучає тип корисного навантаження для конкретного action
  • Lodash: Dictionary<T> - це [key: string]: T під капотом
  • Node.js: process.env типізований як { [key: string]: string | undefined }

Питання на співбесіді

Q: Яка різниця між [key: string]: T і Record<string, T>?
A: Структурно ідентичні. Record<string, T> розгортається в ту саму індексну сигнатуру. Більшість команд обирає Record через зрозумілість синтаксису.

Q: Чому keyof строково-індексованого інтерфейсу повертає string | number?
A: JavaScript перетворює числові ключі в рядки всередині, тому TypeScript додає number, щоб врахувати, що obj[0] і obj["0"] - одне й те саме.

Q: Чи можна ланцюжити типи доступу за індексом?
A: Так. User["address"]["city"] спочатку розв'язує address, потім шукає city в отриманому типі. Працює на будь-якій глибині, доки кожний крок є валідним ключем.

Q: Як працює typeof arr[number] з масивами as const?
A: as const перетворює масив у readonly кортеж з літеральними типами елементів. Індексація через number дає union усіх цих літеральних типів.

Q: Напиши типобезпечну функцію get з використанням типу доступу за індексом.
A:

typescript
function get<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { id: "1", name: "Alice" }; const name = get(user, "name"); // виводиться як string

K extends keyof T обмежує ключі до допустимих, а T[K] зберігає точний тип повернення без жодного приведення.

Приклади

Базовий: типізований словник

typescript
interface Translations { [key: string]: string; } const i18n: Translations = { hello: "привіт", goodbye: "до побачення", }; const greeting = i18n["hello"]; // тип: string, значення: "привіт" const missing = i18n["nope"]; // тип: string, реальне значення: undefined

TypeScript не знає, які ключі існують у рантаймі. Якщо хочеш щоб тип відображав можливий undefined, зміни сигнатуру на [key: string]: string | undefined.

Середній: типізація ендпоінтів API

typescript
interface User { id: string; name: string } interface Post { id: number; title: string } interface ApiResponses { "/users": { users: User[] }; "/posts": { posts: Post[] }; } async function fetchData<T extends keyof ApiResponses>( endpoint: T ): Promise<ApiResponses[T]> { const res = await fetch(endpoint); return res.json(); } const data = await fetchData("/users"); // TypeScript виводить: data - це { users: User[] } // Зміни ендпоінт на "/posts" і тип повернення зміниться автоматично

Тип доступу ApiResponses[T] прив'язує тип повернення до аргументу-ендпоінту. Жодного приведення типів не потрібно.

Просунутий: вилучення вкладених типів

typescript
interface Theme { colors: { primary: string; secondary: string; }; spacing: { sm: number; md: number; lg: number; }; } type ThemeColors = Theme["colors"]; // { primary: string; secondary: string } type SpacingKeys = keyof Theme["spacing"]; // "sm" | "md" | "lg" type SpacingValue = Theme["spacing"][SpacingKeys]; // number // Обобщений хелпер для будь-якої секції type Section<T, K extends keyof T> = T[K]; type ColorSection = Section<Theme, "colors">; // те саме що Theme["colors"]

Ланцюжки типів доступу дозволяють уникнути дублювання визначень інтерфейсів. У коді дизайн-систем цей патерн зустрічається постійно при побудові типізованих утиліт для теми.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?