Как контролировать тип элементов в indexed object TypeScript

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему эта тема взрывает Stack Overflow?

Если вы когда-нибудь писали на TypeScript и пытались создать объект, где все значения должны быть одного типа, вы наверняка сталкивались с этой проблемой. Вопрос "Enforcing the type of the indexed members of a Typescript object" набрал тысячи просмотров на Stack Overflow не случайно — это классическая "грабля" для разработчиков, переходящих с JavaScript.

Представьте: у вас есть словарь кэша, конфигурация с динамическими ключами или просто мапа с числами. Вы хотите, чтобы TypeScript следил за тем, что каждое значение в объекте — строка, число или какой-то сложный тип. Но компилятор почему-то пропускает ошибки. Знакомо?

В этой статье мы разберём все основные подходы к решению этой задачи: от простых Index Signatures до продвинутых Mapped Types. Вы узнаете, когда какой метод использовать и какие подводные камни вас ждут.

Проблема: объект с динамическими ключами

Допустим, мы пишем систему управления пользователями. У нас есть объект, где ключи — это ID пользователей, а значения — их имена:

interface UserCache {
  [userId: string]: string;
}

const cache: UserCache = {
  'user_1': 'Alice',
  'user_2': 'Bob',
  // 'user_3': 123; // Ошибка! Должна быть строка
};

cache['user_4'] = 'Charlie'; // OK
cache['user_5'] = 42; // Ошибка! Type 'number' не assignable к 'string'

Кажется, всё работает. Но есть нюанс: Index Signature разрешает любые строковые ключи. А что если мы хотим ограничить набор допустимых ключей?

Подход 1: Index Signature — классика жанра

Index Signature — самый простой способ задать тип для всех значений объекта. Синтаксис:

interface StringMap {
  [key: string]: string;
}

const map: StringMap = {
  hello: 'world',
  foo: 'bar',
};

Плюсы:

  • Простота и читаемость
  • Работает с любым количеством ключей

Минусы:

  • Нельзя ограничить набор ключей
  • Все значения должны быть одного типа (или union)
  • Не работает с undefined значениями (нужна осторожность)

Частая ошибка новичков: попытка добавить в такой объект метод:

interface BadMap {
  [key: string]: string;
  length: number; // Ошибка! 'number' не assignable к 'string'
}

Это не сработает, потому что length нарушает общий тип. Если нужно смешивать типы, используйте union или Mapped Types.

Подход 2: Record — встроенный хелпер

TypeScript предоставляет встроенный тип Record<K, V>, который делает то же самое, но с более лаконичным синтаксисом:

type StringRecord = Record<string, string>;

const config: StringRecord = {
  apiUrl: 'https://api.example.com',
  timeout: '5000', // OK, строка
  // port: 8080, // Ошибка!
};

// Можно ограничить ключи:
type AllowedKeys = 'name' | 'email' | 'phone';
type UserRecord = Record<AllowedKeys, string>;

const user: UserRecord = {
  name: 'Alice',
  email: 'alice@example.com',
  phone: '+1234567890',
  // age: '30', // Ошибка! 'age' не входит в AllowedKeys
};

Когда использовать:

  • Когда нужно быстро создать типизированный словарь
  • Когда ключи — это конкретный набор значений (union)
  • Для конфигураций с известными полями

Недостаток: Record не позволяет задать разные типы для разных ключей. Для этого есть Mapped Types.

Подход 3: Map — альтернатива из ES6

Если вы используете Map из JavaScript, TypeScript отлично его типизирует:

const userMap = new Map<string, User>();

userMap.set('user_1', { name: 'Alice', age: 30 });
userMap.set('user_2', { name: 'Bob', age: 25 });

// userMap.set(123, 'test'); // Ошибка! Ключ должен быть string
// userMap.set('user_3', 'string'); // Ошибка! Значение должно быть User

Плюсы:

  • Безопасная работа с отсутствующими ключами (метод .get() возвращает undefined | V)
  • Лучшая производительность для частых операций добавления/удаления
  • Встроенные методы: .has(), .delete(), .forEach()

Минусы:

  • Нельзя сериализовать в JSON напрямую
  • Чуть более громоздкий синтаксис
  • Не подходит, если нужен объект с фиксированными ключами

Совет: Используйте Map, когда вам нужна коллекция с динамическими ключами и частыми операциями поиска. Для простых словарей используйте Record или Index Signature.

Подход 4: Mapped Types — гибкость для сложных случаев

Mapped Types — это мощный инструмент, который позволяет создавать типы на основе других типов. Идеально, когда нужно задать разные типы для разных ключей:

// Базовый тип конфигурации
type Config = {
  apiUrl: string;
  timeout: number;
  retryCount: number;
};

// Создаём тип, где каждое значение — это Promise соответствующего типа
type AsyncConfig = {
  [K in keyof Config]: Promise<Config[K]>;
};

// Использование:
const asyncConfig: AsyncConfig = {
  apiUrl: Promise.resolve('https://api.example.com'),
  timeout: Promise.resolve(5000),
  retryCount: Promise.resolve(3),
};

Или более практичный пример — валидация всех полей объекта:

type ErrorMap<T> = {
  [K in keyof T]: string | null;
};

interface UserForm {
  name: string;
  email: string;
  age: number;
}

const errors: ErrorMap<UserForm> = {
  name: 'Имя обязательно',
  email: null, // OK, может быть null
  age: 'Возраст должен быть числом',
  // phone: 'Ошибка', // 'phone' не существует в UserForm
};

Плюсы:

  • Максимальная гибкость
  • Возможность трансформировать типы
  • Полная безопасность типов

Минусы:

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

Блоги

Рекомендаций по книгам