Другое

Полное руководство: Утилитарный тип TypeScript для неизвестных свойств

Полное руководство по созданию утилитарных типов TypeScript, которые сохраняют известные свойства, позволяя неизвестные свойства только в виде строк. Узнайте лучшие практики и методы реализации.

Проблема с утилитарным типом и неизвестными свойствами

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

Вот что я сделал до сих пор:

typescript
type MyType<T> = {
  [K in string]?: K extends keyof T ? (T[K] extends Callable ? never : T[K]) : string;
};

interface A {
  foo: string;
  bar: number;
  callMe(): void;
}

const test: MyType<A> = {
  bar: 42, // Это должно работать, но не работает: Тип 'number' не может быть присвоен типу 'string'
  callMe: () => {}, // ✅ Ошибка TypeScript: Работает нормально из-за Callable
  customAttr: 'customValue', // ✅ Неизвестные ключи должны иметь строковое значение
  foo: 'hello', // ✅ Известные ключи должны соответствовать типу
};

Как мне модифицировать этот утилитарный тип, чтобы правильно сохранять исходные типы известных свойств, при этом позволяя устанавливать неизвестные свойства только как строки и предотвращая использование Callable свойств?

Создание утилитарного типа TypeScript, который сохраняет известные свойства, разрешает неизвестные свойства только в виде строк и исключает вызываемые (функциональные) свойства, требует тщательного подхода, который позволяет избежать конфликтов типов. Вот решение:

typescript
type MyType<T> = {
  [K in keyof T as T[K] extends Callable ? never : K]: T[K];
} & {
  [key: string]: string;
};

Однако этот подход имеет фундаментальную проблему: TypeScript будет жаловаться на конфликтующие типы свойств (например, bar: number конфликтует с [key: string]: string). Правильное решение заключается в использовании условного типа, который обрабатывает известные и неизвестные свойства вместе:

typescript
type MyType<T> = {
  [K in keyof T]: T[K] extends Callable ? never : T[K];
} & {
  [key: string]: string;
};

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

typescript
type MyType<T> = {
  [K in keyof T as T[K] extends Callable ? never : K]: T[K];
} & {
  [key: string]: string;
};

Содержание


Понимание проблемы

Сложность заключается в создании типа, который:

  1. Сохраняет существующие невызываемые свойства с их исходными типами
  2. Разрешает дополнительные свойства, но только в виде строк
  3. Полностью исключает вызываемые (функциональные) свойства

Проблема вашего первоначального подхода заключается в том, что [K in string] создает отображаемый тип, который пытается переопределить все возможные строковые ключи, что конфликтует с конкретными типами известных свойств.

Правильное решение с использованием типов-пересечений

Наиболее эффективное решение использует два пересекающихся типа:

typescript
type MyType<T> = {
  // Известные свойства, исключая вызываемые
  [K in keyof T as T[K] extends Callable ? never : K]: T[K];
} & {
  // Неизвестные свойства в виде строк
  [key: string]: string;
};

Это работает потому, что:

  1. Первая часть отфильтровывает вызываемые свойства с помощью as T[K] extends Callable ? never : K
  2. Вторая часть обеспечивает резервный вариант для всех остальных строковых свойств
  3. Система типов TypeScript автоматически разрешает конфликты, отдавая предпочтение более конкретным типам

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

Обработка вызываемых свойств

Ключевая сложность - правильное исключение вызываемых свойств. Тип Callable не является встроенным, поэтому нам нужно определить его:

typescript
type Callable = (...args: any[]) => any;

type MyType<T> = {
  [K in keyof T as T[K] extends Callable ? never : K]: T[K];
} & {
  [key: string]: string;
};

Как объясняет команда TypeScript из Microsoft, фильтрация вызываемых свойств требует тщательной манипуляции типами для поддержания типобезопасности при удалении сигнатур функций.

Альтернативные подходы

Использование условных типов с сигнатурами индексов

Другой подход использует условные типы с сигнатурами индексов:

typescript
type NonCallableValues<T> = {
  [K in keyof T]: T[K] extends Callable ? never : T[K];
};

type MyType<T> = NonCallableValues<T> & {
  [key: string]: string;
};

Этот подход более читаем и следует шаблону, показанному в решениях на Stack Overflow.

Использование утилитарного типа Exclude

Вы также можете использовать встроенный утилитарный тип TypeScript Exclude:

typescript
type MyType<T> = {
  [K in keyof T]: T[K] extends Callable ? never : T[K];
} & {
  [key: string]: string;
};

Этот подход особенно эффективен при работе со сложными преобразованиями типов, как отмечено в заметках о выпуске TypeScript 2.8.

Полный рабочий пример

Вот полное решение с вашим интерфейсом:

typescript
type Callable = (...args: any[]) => any;

type MyType<T> = {
  [K in keyof T as T[K] extends Callable ? never : K]: T[K];
} & {
  [key: string]: string;
};

interface A {
  foo: string;
  bar: number;
  callMe(): void;
}

const test: MyType<A> = {
  bar: 42, // ✅ Работает: тип number сохранен
  // callMe: () => {}, // ❌ Ошибка TypeScript: вызываемое свойство правильно исключено
  customAttr: 'customValue', // ✅ Неизвестный ключ со строковым значением
  foo: 'hello', // ✅ Известный ключ с правильным строковым типом
};

Дополнительные соображения

Вопросы производительности

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

Крайние случаи

Будьте внимательны к этим крайним случаям:

  • Свойства со сложными типами, которые могут пересекаться со строковыми
  • Свойства с символами (хотя они менее распространены в этом шаблоне)
  • Необязательные свойства в исходном типе

Совместимость с версиями TypeScript

Это решение работает в TypeScript 3.4+, когда отображаемые типы с условными типами были стабилизированы. Для более старых версий могут потребоваться обходные пути или обновление версии TypeScript.

Ключевое понимание заключается в том, что система типов TypeScript разрешает конфликты свойств, отдавая предпочтение более конкретным типам перед общими, что позволяет этому шаблону пересечения правильно работать как для известных, так и для неизвестных свойств.


Источники

  1. Документация TypeScript: Отображаемые типы
  2. Stack Overflow: Проблема с утилитарным типом и неизвестными свойствами
  3. TypeScript: Продвинутые типы
  4. Microsoft/TypeScript: Фильтрация вызываемых свойств
  5. Заметки о выпуске TypeScript 2.8

Заключение

Решение для создания утилитарного типа TypeScript, который сохраняет известные свойства, разрешает неизвестные свойства только в виде строк и исключает вызываемые свойства, включает:

  1. Использование отображаемых типов с условной логикой для фильтрации вызываемых свойств
  2. Пересечение с сигнатурой индекса для неизвестных строковых свойств
  3. Использование разрешения конфликтов типов TypeScript для обработки известных и неизвестных свойств

Финальный утилитарный тип:

typescript
type MyType<T> = {
  [K in keyof T as T[K] extends Callable ? never : K]: T[K];
} & {
  [key: string]: string;
};

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

Авторы
Проверено модерацией
Модерация