Утилітарний тип record у TypeScript
Record<K, T> - це utility-тип у TypeScript, який створює тип об'єкта, де кожен ключ з union K є обов'язковим і відповідає типу T.
Теорія
TL;DR
- Аналогія: Record - це шаблон таблиці, де заголовки стовпців фіксовані, а кожна клітинка містить дані одного типу.
- Головна різниця від
{ [key: string]: T }: Record вимагає ВСІ ключі з union в об'єкті; index-сигнатури приймають будь-який рядок без перевірок. - Всередині
Record<K, T>розгортається у{ [P in K]: T }- це mapped-тип. - Правило вибору: Record - якщо ключі відомі наперед. Index-сигнатура або
Map- якщо ключі динамічні.
Швидкий приклад
type Status = "pending" | "approved" | "rejected";
type StatusConfig = Record<Status, { label: string; color: string }>;
const config: StatusConfig = {
pending: { label: "Очікує", color: "#FFA500" },
approved: { label: "Виконано", color: "#00AA00" },
rejected: { label: "Відхилено", color: "#FF0000" }
// Прибери будь-який ключ - TypeScript одразу покаже помилку.
};Усі три ключі обов'язкові. В цьому весь контракт.
Ключова різниця
Record гарантує повноту покриття. Якщо оголосити Record<"a" | "b" | "c", T>, TypeScript вимагає всі три ключі під час компіляції. Index-сигнатура { [key: string]: T } приймає будь-який рядок без перевірок. Record робить контракти явними. Index-сигнатури дають гнучкість.
Коли використовувати
- Конфіги з відомими ключами: ролі до дозволів, статус-коди до повідомлень, feature-флаги до boolean.
- Lookup-таблиці: ID користувачів до профілів, коди валют до курсів.
- Стейт-машини: кожен стан відображається на дозволені переходи або обробники.
- Не використовуй Record, якщо ключі справді динамічні або необмежені. Там краще підходить index-сигнатура або
Map.
Як компілятор це обробляє
TypeScript розгортає Record<K, T> у { [P in K]: T }. Компілятор перебирає кожен елемент union K і створює обов'язкову властивість. Якщо якийсь ключ відсутній - помилка компіляції. В рантаймі накладних витрат немає. Record компілюється у звичайний JavaScript-об'єкт.
Типові помилки
Помилка 1: Забутий ключ після розширення union
Найчастіше бачу це після рефакторингу: хтось додає нове значення до union і не оновлює об'єкт.
type Permission = "read" | "write" | "delete" | "admin";
type PermissionConfig = Record<Permission, boolean>;
// Помилка TypeScript: властивість 'admin' відсутня
const config: PermissionConfig = {
read: true,
write: true,
delete: true,
};
// Виправлення: додай усі ключі
const configFixed: PermissionConfig = {
read: true,
write: true,
delete: true,
admin: false,
};Помилка 2: Record там, де потрібні опціональні ключі
type Feature = "darkMode" | "analytics" | "beta";
// Record вимагає всі ключі, тому це не спрацює:
const flags: Record<Feature, boolean> = {
darkMode: true,
analytics: true,
// Помилка: відсутній "beta"
};
// Виправлення: загорни у Partial
const flagsFixed: Partial<Record<Feature, boolean>> = {
darkMode: true,
analytics: true, // Тепер валідно
};Помилка 3: Record<string, T> замість конкретного union
// Це просто index-сигнатура під іншою назвою
type D = Record<string, number>; // Те саме, що { [key: string]: number }
// Якщо ключі відомі - перелічи їх явно
type Currency = "USD" | "EUR" | "GBP";
const rates: Record<Currency, number> = {
USD: 1.0,
EUR: 0.92,
GBP: 0.87, // TypeScript вимагає всі три
};Де зустрічається в реальних проєктах
- React: реєстри компонентів
Record<ComponentName, ComponentType> - Redux: маппінг action-типів до обробників
Record<ActionType, ActionCreator> - Express: HTTP-метод до обробника
Record<"GET" | "POST" | "DELETE", RequestHandler> - Тести: фабрики моків
Record<UserRole, MockUser>
Питання на співбесіді
Q: У чому різниця між Record<K, T> і { [P in K]: T }?
A: Вони еквівалентні. Record - це синтаксичний цукор над mapped-типом. Використовуй Record для простих контрактів ключ-значення. Mapped-тип пиши напряму, якщо потрібна умовна логіка для кожного ключа: { [P in K]: P extends "admin" ? AdminConfig : UserConfig }.
Q: Як зробити частину ключів Record опціональними?
A: Partial<Record<K, T>> робить усі ключі опціональними. Якщо хочеш зберегти всі ключі обов'язковими, але дозволити undefined, використовуй Record<K, T | undefined>.
Q: Коли обрати Map<string, T> замість Record<string, T>?
A: Record підходить для статичних типізованих конфігурацій, які компілюються у звичайні об'єкти. Map - для динамічних даних з необмеженими ключами і вбудованими методами ітерації. Конфіги - Record. Кеші або дані від користувача - Map.
Q: Що станеться, якщо додати зайвий ключ у рантаймі?
A: JavaScript дозволяє. Record перевіряє контракт лише під час компіляції. TypeScript покаже помилку типу для config.newKey = "value", але присвоєння виконається.
Приклади
Маппінг дозволів за ролями
type UserRoles = "admin" | "user" | "guest";
type RolePermissions = Record<UserRoles, string[]>;
const rolePermissions: RolePermissions = {
admin: ["create", "edit", "delete"],
user: ["view", "edit"],
guest: ["view"],
};
// TypeScript знає: це string[], а не string[] | undefined
console.log(rolePermissions["admin"]); // ["create", "edit", "delete"]Кожен ключ з UserRoles обов'язковий. Додай "moderator" до union - TypeScript одразу вимагатиме властивість moderator в об'єкті.
Обробники HTTP-методів в Express
import { Request, Response } from "express";
type RouteHandlers = Record<
"GET" | "POST" | "DELETE",
(req: Request, res: Response) => void
>;
const handlers: RouteHandlers = {
GET: (req, res) => res.json({ data: [] }),
POST: (req, res) => res.status(201).json({}),
DELETE: (req, res) => res.status(204).send(),
};Додай новий HTTP-метод до union - код не скомпілюється, поки не напишеш його обробник. Тип забезпечує повне покриття автоматично.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.