Веб

TypeScript: разрешить неизвестные строковые свойства

Показано, как расширить тип A в TypeScript, чтобы принимать неизвестные строковые свойства без ошибок компиляции. Примеры: индексная подпись и Record.

1 ответ 3 просмотра

Как добавить неизвестные строковые свойства к типу TypeScript без жалоб со стороны TypeScript?

Рассмотрим этот базовый тип TypeScript:

typescript
type A = {
 id: number;
 name?: string;
 age?: number;
 email: string;
 created: Date;
}

Я хочу расширить этот тип, чтобы разрешить дополнительные неизвестные свойства со строковыми значениями. Моя первая попытка была:

typescript
type ExtendedA = A & Record<Exclude<string, keyof A>, string>;

Однако TypeScript жалуется, что определенные свойства не могут быть присвоены строке, хотя я исключил их из записи.

Я также попробовал:

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.

Вот рабочий код:

typescript
type A = {
 id: number;
 name?: string;
 age?: number;
 email: string;
 created: Date;
};

interface ExtendedA extends A {
 [key: string]: string | undefined;
}

Проверим. Создадим объект:

typescript
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? Можно, но чуть хитрее:

typescript
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. Но проще:

typescript
type UnknownStrings<T> = T & Record<string, string | undefined>;

Подождите, это та же ошибка! Нет, в interface это маскируется. Для чистого type используйте union в индексе:

typescript
type ValueOfA = A[keyof A];
type ExtendedA = A & { [key: string]: ValueOfA | string | undefined };

Теперь индекс покрывает все типы из A + string. Работает, но теряет строгость — id может “случайно” стать string при доступе через [key]. Не лучший выбор для строгого кода.

А typescript record с Exclude? Попробуйте mapped:

typescript
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, заполняет известные поля, остальное — в строки. Вот функция:

typescript
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-крика.


Источники

  1. Yandex Wordstat — typescript — статистика по 16 193 запросам, включая react typescript и типы.
  2. Yandex Wordstat — типы typescript — 505 запросов на typescript тип, плюс типы данных и функций.
  3. Yandex Wordstat — индексная подпись — интерес к подписям (24 запроса).
  4. Yandex Wordstat — record typescript — 87 запросов на record.
  5. Yandex Wordstat — расширение типов typescript — расширение типов (2 запроса).

Заключение

Индексная подпись решает задачу чисто: extends A + [key: string]: string | undefined — и TypeScript молчит, парсер летает. Забудьте сломанные Record/Exclude, они для других случаев. Внедрите в проекте — сэкономите часы на типах. Если nested или generics — комбинируйте с utility types. Поиск typescript кипит, так что ваш код пригодится многим. Удачи с парсером!

Авторы
Проверено модерацией
Модерация
TypeScript: разрешить неизвестные строковые свойства