Введение: Почему TypeScript ругается на ваш код?
Вы когда-нибудь писали на TypeScript код, который кажется абсолютно логичным, но компилятор вдруг выдаёт пугающую ошибку? Если вы сталкивались с сообщением «An index signature parameter type cannot be be a union type. Consider using a mapped object type instead» — вы не одиноки. Эта ошибка — один из самых популярных вопросов на Stack Overflow (тысячи просмотров!), и она часто ставит в тупик даже опытных разработчиков.
В этой статье мы не просто покажем, как исправить ошибку. Мы разберём, почему TypeScript так строг, и как использовать mapped object types, чтобы ваш код стал не только рабочим, но и более безопасным. Поехали!
Что такое index signature и зачем он нужен?
Представьте, что вы создаёте объект, у которого ключи — это строки, а значения — числа. Например, словарь с ценами на товары:
// Обычный объект
const prices = {
apple: 100,
banana: 50,
cherry: 200
};
Но что, если вы хотите сказать TypeScript: «Слушай, у этого объекта могут быть любые строковые ключи, и все значения будут числами»? Для этого и нужен index signature — специальный синтаксис, который описывает шаблон для всех свойств объекта:
// Index signature: [key: string]: number
interface PriceMap {
[key: string]: number;
}
const prices: PriceMap = {
apple: 100,
banana: 50
};
Здесь [key: string]: number — это и есть index signature. Он говорит: «Ключи — строки, значения — числа». Всё просто, правда?
А в чём проблема с union-типами?
Теперь представьте, что вы хотите ограничить возможные ключи только двумя вариантами: 'name' или 'age'. Кажется логичным написать так:
// ❌ ОШИБКА! Так нельзя
interface Person {
[key: 'name' | 'age']: string | number;
}
И тут TypeScript выдаёт ту самую ошибку: «An index signature parameter type cannot be a union type. Consider using a mapped object type instead».
Почему? Потому что index signature предназначен для описания неограниченного набора ключей. Если вы хотите задать конкретный список ключей, это уже не шаблон, а точное описание структуры. TypeScript не позволяет смешивать эти концепции.
Как исправить ошибку? Используем Mapped Object Type
Решение, которое предлагает сам компилятор — использовать mapped object type (сопоставленный объектный тип). Звучит сложно, но на деле это просто способ «перебрать» все ключи из union-типа и создать для каждого своё свойство.
Вот как это выглядит:
// ✅ ПРАВИЛЬНО: используем mapped type
type Person = {
[K in 'name' | 'age']: K extends 'name' ? string : number;
};
// Или более простой вариант, если типы значений одинаковы:
type PersonSimple = {
[K in 'name' | 'age']: string | number;
};
Разберём синтаксис [K in 'name' | 'age']:
K— это переменная, которая по очереди принимает каждое значение из union-типа ('name', потом'age').in— ключевое слово, которое запускает «цикл» по типам.'name' | 'age'— наш набор ключей.
В результате TypeScript создаст объект с двумя конкретными свойствами: name и age. Ошибка исчезла!
Пример с разными типами значений
Часто бывает, что разные ключи должны иметь разные типы значений. С помощью условных типов (conditional types) это решается элегантно:
type Config = {
[K in 'host' | 'port' | 'debug']:
K extends 'host' ? string :
K extends 'port' ? number :
boolean;
};
// Теперь:
const config: Config = {
host: 'localhost', // ✅ строка
port: 3000, // ✅ число
debug: true // ✅ булево
};
Этот код читается как: «Для каждого ключа K из списка, если K — это 'host', то тип — строка; если 'port' — число; иначе — булево». Красиво и безопасно!
Альтернативные решения (когда mapped type не подходит)
Иногда mapped object type — не единственный выход. Рассмотрим пару альтернатив:
1. Использовать Record-тип
Record<K, V> — это встроенный утилитарный тип TypeScript, который делает то же самое, что и mapped type, но с более коротким синтаксисом:
type Person = Record<'name' | 'age', string | number>;
// Эквивалентно:
// type Person = { [K in 'name' | 'age']: string | number };
Минус: Record задаёт один и тот же тип для всех ключей. Если нужны разные типы — придётся вернуться к mapped type + conditional.
2. Просто перечислить свойства вручную
Самый очевидный способ — не использовать index signature вообще:
interface Person {
name: string;
age: number;
}
Это работает, если количество ключей фиксировано и вы знаете их заранее. Но если ключи динамические или их много — лучше использовать mapped type.
Почему TypeScript вообще так сделал? Глубокое погружение
Возможно, вы думаете: «Ну почему нельзя было разрешить index signature с union? Это же так удобно!»
Причина — в безопасности типов. Представьте, что TypeScript разрешил бы такой код:
// Гипотетически (но так нельзя!)
interface Bad {
[key: 'a' | 'b']: string;
}
const obj: Bad = { a: 'hello' };
obj.b; // ✅ OK, но что если b нет?
Если бы index signature работал с union, то TypeScript считал бы, что все ключи из union обязательно есть в объекте. Но это не так — вы можете создать объект только с одним ключом. Возникает несоответствие: тип говорит «у тебя есть и a, и b», а реально есть только a. Это приводит к ошибкам в рантайме.
Mapped type решает эту проблему: он создаёт конкретные свойства, и TypeScript может проверить, все ли они присутствуют.
Практический пример: конфигурация приложения
Давайте закрепим на реальной задаче. Допустим, вы пишете конфиг для приложения, где ключи — это названия серверов, а значения — их адреса:
// ❌ Неправильно: ошибка!
type ServerConfigBad = {
[key: 'api' | 'cdn' | 'db']: string;
};
// ✅