Індекси підписів та типи доступу до індексів у TypeScript
Індексна сигнатура (index signature) описує тип для об'єктів з довільними рядковими або числовими ключами. Тип доступу за індексом (T[K]) читає тип конкретної властивості з наявного типу на рівні компілятора. Це два різні інструменти для двох різних задач.
Теорія
TL;DR
- Індексна сигнатура (
[key: string]: T) = контракт на структуру об'єкта з довільними ключами - Тип доступу за індексом (
T["key"]) = зчитування типу властивості з іншого типу - Аналогія: сигнатура - це правило готелю (будь-яка картка відкриває номер одного класу); тип доступу - це читання категорії номера з плану поверху
- Сигнатура впливає на те, що можна записати в рантаймі; тип доступу не генерує жодного JS-коду
Record<string, T>і[key: string]: Tструктурно ідентичні;Recordкоротший і зрозуміліший
Короткий приклад
// Індексна сигнатура: будь-який рядковий ключ відображається на рядок
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 для чистих словників, а окремі інтерфейси для структурованих даних.
Типові помилки
Відомі властивості конфліктують з індексною сигнатурою
interface Config {
name: string;
version: number; // Помилка: number не присвоюється до string
[key: string]: string;
}Кожна явна властивість повинна задовольняти сигнатуру. Рішення - розширити тип:
interface Config {
name: string;
version: number;
[key: string]: string | number; // тепер сумісно
}Використання T[string] замість T[keyof T]
type Obj = { a: 1; b: 2 };
type Bad = Obj[string]; // Помилка: 'string' не є допустимим типом індексу
type Good = Obj[keyof Obj]; // 1 | 2string занадто широкий. Використовуй keyof Obj або конкретний union літералів.
Запис у readonly сигнатуру
interface ReadDict {
readonly [key: string]: string;
}
const d: ReadDict = { a: "ok" };
d["b"] = "no"; // Помилка: індексна сигнатура тільки для читанняПрибери readonly, якщо потрібна мутація. Залишай лише якщо незмінюваність задумана навмисно.
Забуваєш as const при доступі до елементів масиву
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:
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"); // виводиться як stringK extends keyof T обмежує ключі до допустимих, а T[K] зберігає точний тип повернення без жодного приведення.
Приклади
Базовий: типізований словник
interface Translations {
[key: string]: string;
}
const i18n: Translations = {
hello: "привіт",
goodbye: "до побачення",
};
const greeting = i18n["hello"]; // тип: string, значення: "привіт"
const missing = i18n["nope"]; // тип: string, реальне значення: undefinedTypeScript не знає, які ключі існують у рантаймі. Якщо хочеш щоб тип відображав можливий undefined, зміни сигнатуру на [key: string]: string | undefined.
Середній: типізація ендпоінтів API
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] прив'язує тип повернення до аргументу-ендпоінту. Жодного приведення типів не потрібно.
Просунутий: вилучення вкладених типів
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"]Ланцюжки типів доступу дозволяють уникнути дублювання визначень інтерфейсів. У коді дизайн-систем цей патерн зустрічається постійно при побудові типізованих утиліт для теми.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.