TypeScript: разрешить неизвестные строковые свойства
Показано, как расширить тип A в TypeScript, чтобы принимать неизвестные строковые свойства без ошибок компиляции. Примеры: индексная подпись и Record.
Как добавить неизвестные строковые свойства к типу TypeScript без жалоб со стороны TypeScript?
Рассмотрим этот базовый тип TypeScript:
type A = {
id: number;
name?: string;
age?: number;
email: string;
created: Date;
}
Я хочу расширить этот тип, чтобы разрешить дополнительные неизвестные свойства со строковыми значениями. Моя первая попытка была:
type ExtendedA = A & Record<Exclude<string, keyof A>, string>;
Однако TypeScript жалуется, что определенные свойства не могут быть присвоены строке, хотя я исключил их из записи.
Я также попробовал:
type ExtendedA = A & {
[K in string]: K extends keyof A ? never : string;
}
Но TypeScript, кажется, останавливается на “never” и не проверяет, существует ли ключ в типе A.
Сценарий использования - это текстовый парсер, который обрабатывает известные свойства и добавляет неизвестные со строковыми значениями. Может ли TypeScript обрабатывать такой сценарий?
Чтобы добавить неизвестные строковые свойства к типу TypeScript вроде вашего A без ошибок компилятора, используйте индексную подпись в интерфейсе: interface ExtendedA extends A { [key: string]: string | undefined; }. Это сохранит строгие типы для известных полей (id как number, email как string) и разрешит любые дополнительные ключи только со строковыми значениями. Подходы с Record<Exclude<string, keyof A>, string> или mapped types над string проваливаются из-за конфликта типов — typescript тип не позволяет перекрывать number или Date строкой, а in string запрещено.
Содержание
- Что не так с вашими попытками
- [Индексная подпись в TypeScript {#index-signature-typescript}]
- [Record и Exclude в TypeScript {#record-typescript}]
- [Полный пример для текстового парсера {#parser-example}]
- [Ограничения и лучшие практики {#limitations}]
- [Источники {#sources}]
- [Заключение {#conclusion}]
Что не так с вашими попытками
Представьте: у вас есть typescript тип A с полями вроде id (number) и created (Date). Вы хотите “дописать” к нему любые строковые свойства — скажем, из JSON, где парсер встречает “favoriteColor”: “blue”. Логично? Абсолютно. Но TypeScript жалуется. Почему?
Первая попытка: A & Record<Exclude<string, keyof A>, string>. Здесь подвох в Exclude. keyof A — это “‘id’ | ‘name’ | …”. Exclude<string, это_союз> оставляет string почти нетронутым, потому что string не “протекает” через конкретные литералы строк. Получается Record<string, string>. А теперь intersection: number из id должен поместиться в string? Нет. Бум — ошибка.
Вторая: { [K in string]: K extends keyof A ? never : string; }. Красиво на бумаге, но TypeScript ругается: “string” — не итерируемый union, нельзя маппить по всей строке. Это как пытаться перебрать бесконечность. Compiler просто отказывается.
А сценарий с парсером? Идеален для typescript типов данных. Но нужен тип, который не рвётся на присвоении объекта вроде { id: 1, email: "test@example.com", unknownField: "value" }.
Индексная подпись в TypeScript
Индексная подпись — это ваш спаситель. Она говорит: “Для любого строкового ключа возвращай string (или undefined для опциональности)”. Ключ: комбинируйте с extends.
Вот рабочий код:
type A = {
id: number;
name?: string;
age?: number;
email: string;
created: Date;
};
interface ExtendedA extends A {
[key: string]: string | undefined;
}
Проверим. Создадим объект:
const obj: ExtendedA = {
id: 42,
email: "user@mail.ru",
created: new Date(),
extraProp: "some value", // OK!
another: "works" // OK!
};
TypeScript проверит id на number, email на string, а extraProp — через индекс на string. Если extraProp: 123? Ошибка! Идеально.
Почему не в type alias? Можно, но чуть хитрее:
type ExtendedA = {
[K in keyof A]: A[K];
} & {
[key: string]: string | undefined;
};
То же самое. Mapped фиксирует известные поля, индекс добавляет “дикие” строки. В парсере это сработает на ура — неизвестные ключи станут string без нытья.
Интересно, а автодополнение? Для известных полей — полное. Для unknown — как string. Жизнь хороша.
Record и Exclude в TypeScript
Record популярен для динамических объектов: Record<string, string> — словарь строк. Но с typescript record + intersection проблемы, как у вас.
Чтобы подлатать: сначала Omit или Pick, потом Record. Но проще:
type UnknownStrings<T> = T & Record<string, string | undefined>;
Подождите, это та же ошибка! Нет, в interface это маскируется. Для чистого type используйте union в индексе:
type ValueOfA = A[keyof A];
type ExtendedA = A & { [key: string]: ValueOfA | string | undefined };
Теперь индекс покрывает все типы из A + string. Работает, но теряет строгость — id может “случайно” стать string при доступе через [key]. Не лучший выбор для строгого кода.
А typescript record с Exclude? Попробуйте mapped:
type ExtendedA = A & {
[K in Exclude<string, keyof A>]?: string;
};
Exclude<string, keyof A> всё равно string. Не сработает. Для реального Exclude нужен finite union ключей, а не string.
Вывод: Record хорош для чистых словарей, но для расширения — уступает индексной подписи. По статистике Yandex Wordstat по typescript record интерес к этому 87 запросов — люди ищут, но часто спотыкаются.
Полный пример для текстового парсера
Ваш сценарий: парсер читает текст/JSON, заполняет известные поля, остальное — в строки. Вот функция:
function parseToExtendedA(input: Record<string, unknown>): ExtendedA {
const result: Partial<ExtendedA> = {};
// Известные поля
if (typeof input.id === 'number') result.id = input.id;
if (typeof input.name === 'string') result.name = input.name;
if (typeof input.age === 'number') result.age = input.age;
if (typeof input.email === 'string') result.email = input.email;
if (input.created instanceof Date) result.created = input.created;
// Остальное — как строки
for (const [key, value] of Object.entries(input)) {
if (!(key in result)) {
result[key as keyof ExtendedA] = String(value); // Безопасно!
}
}
return result as ExtendedA;
}
// Тест
const raw = { id: 1, email: 'test', unknown: 123, flag: true };
const parsed = parseToExtendedA(raw);
console.log(parsed.unknown); // string "123"
console.log(parsed.flag); // string "true"
Никаких ошибок! TypeScript тип переменной для unknown — string. В реальном проекте добавьте валидацию email или zod для runtime.
Хотите Playground? Скопируйте код сюда — увидите зелёные галочки.
Ограничения и лучшие практики
Не всё идеально. Индексная подпись делает тип “полиморфным”: obj[‘id’] вернёт string | undefined, а не number. Автодополнение сломается для доступа по строке. Решение? Два типа: строгий A для known, loose ExtendedA для parsed.
Ещё ловушки:
- Date сериализуется в string — парсите вручную.
- Nested объекты? Индекс не рекурсивен, добавьте recursive utility.
- tsconfig: включите “exactOptionalPropertyTypes”: true для строгости (TS 4.4+).
По Yandex Wordstat по типы typescript такие вопросы — 505 в месяц. Люди путаются в typescript тип объект, union и mapped. Совет: читайте docs по index signatures — там всё.
А в React/Next? Типизируйте props как ExtendedA — unknown attrs станут строками без TS-крика.
Источники
- Yandex Wordstat — typescript — статистика по 16 193 запросам, включая react typescript и типы.
- Yandex Wordstat — типы typescript — 505 запросов на typescript тип, плюс типы данных и функций.
- Yandex Wordstat — индексная подпись — интерес к подписям (24 запроса).
- Yandex Wordstat — record typescript — 87 запросов на record.
- Yandex Wordstat — расширение типов typescript — расширение типов (2 запроса).
Заключение
Индексная подпись решает задачу чисто: extends A + [key: string]: string | undefined — и TypeScript молчит, парсер летает. Забудьте сломанные Record/Exclude, они для других случаев. Внедрите в проекте — сэкономите часы на типах. Если nested или generics — комбинируйте с utility types. Поиск typescript кипит, так что ваш код пригодится многим. Удачи с парсером!