Создание утилиты типов — выведение интерфейсов из объединенных типов в TypeScript

В этой заметке мы будем создавать небольшие универсальные утилиты типов в TypeScript.

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

type Dispatcher = {
  [Message in Action as Message['type']]: Message extends { payload: any }
    ? (payload: Message['payload']) => void
    : () => void;
};
Вход в полноэкранный режим Выход из полноэкранного режима

Негенерический код, который трудно использовать повторно ⤴

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

type Dispatcher =
  ValuesAsCallbacks<UnionToKeyValue<Action, 'type:payload'>>;
Войти в полноэкранный режим Выход из полноэкранного режима

Многоразовые, универсальные утилиты типов ⤴

Теперь давайте посмотрим, как это создать 🤔.

Двухэтапный процесс

Сначала давайте разберем процесс превращения одного типа в другой. Мы рассмотрим реализацию и более подробное объяснение в следующем разделе.

1️⃣ Превращение союза в ключ-значение

Во-первых, мы превращаем union в интерфейс ключ-значение, предоставляя строковый литерал, ограниченный двоеточием, определяющий свойство, которое будет использоваться в качестве ключа, и свойство, которое будет использоваться в качестве значения. В случае, если свойство отсутствует у какого-либо элемента из союза, мы будем представлять этот факт с помощью типа never:

type Action =
  | { type: 'reset' }
  | { type: 'setValue'; payload: string }
  | { type: 'setSelection'; payload: [number, number] | null }
  | { type: 'trimWhitespace'; payload: 'leading'|'trailing'|'both' };

type IntermediateResult = UnionToKeyValue<Action, 'type:payload'>;
         
┌───────────────────────────────────────────────────────────────────┐
 type IntermediateResult = {                                       
   reset: never;                                                   
   setValue: string;                                               
   setSelection: [number, number] | null;                          
   trimWhitespace: 'leading'|'trailing'|'both';                    
 }                                                                 
└───────────────────────────────────────────────────────────────────┘
Вход в полноэкранный режим Выйти из полноэкранного режима

Вы уже видите, как это превращается в интерфейс с обратными вызовами?

2️⃣ Values As Callbacks

Во-вторых, мы превращаем интерфейс «ключ-значение» в интерфейс «ключ-обратный вызов», где функция обратного вызова принимает параметр того же типа, что и значение в первом случае:

type IntermediateResult = {
  reset: never;
  setValue: string;
  setSelection: [number, number] | null;
  trimWhitespace: 'leading'|'trailing'|'both';
}

type Dispatcher = ValuesAsCallbacks<IntermediateResult>;
         
┌───────────────────────────────────────────────────────────────────┐
 type Dispatcher = {                                               
   reset: () => void;                                              
   setValue: (payload: string) => void;                            
   setSelection: (payload: [number, number] | null) => void;       
   trimWhitespace: (payload: 'leading'|'trailing'|'both') => void; 
 }                                                                 
└───────────────────────────────────────────────────────────────────┘
Вход в полноэкранный режим Выход из полноэкранного режима

Реализация и небольшое пояснение

Союз ключ-значение

Давайте рассмотрим, как реализовать первый полезный тип 🕵️

type UnionToKeyValue<
  T extends { [key: string]: any },  
  K extends string  
> = ...
Вход в полноэкранный режим Выход из полноэкранного режима

(1) В качестве первого параметра утилита принимает интерфейс или объединение интерфейсов, индексированных ключами, являющимися строками или строковыми литералами.

(2) Он также требует, чтобы второй параметр был строкой или строковым литералом.

... = K extends `${infer Key}:${infer Value}`  
  ? Key extends keyof T  
    ? ...
    : never
  : never;
Вход в полноэкранный режим Выход из полноэкранного режима

(3) Key и Value никогда не появлялись в качестве параметров типа util. Благодаря шаблонным литеральным типам мы можем определить оба типа по типу, указанному в качестве второго параметра, если это строка или строковый литерал, и если он содержит двоеточие внутри.

Учитывая 'type:payload', предоставленный в качестве второго параметра, мы сделаем вывод, что Key — это 'type', а Value — это 'payload'.

Если мы не найдем двоеточие во втором параметре, то мы не сможем выполнить поиск по образцу и вернем never.

(4) Далее, имея найденный ключ, мы дважды проверяем его принадлежность к набору ключей первого параметра, T — интерфейса или объединения интерфейсов. Если он не принадлежит, мы возвращаем never.

? { [A in T as A[Key]]: Value extends keyof A ? A[Value] : never }  
...
Вход в полноэкранный режим Выйти из полноэкранного режима

(5) Теперь мы используем сопоставленные типы для итерации по T, что подразумевает, что T должен быть объединением типов, а не одним интерфейсом, чтобы сделать сопоставление возможным. В интерфейсе мы бы увидели использование keyof.

Использование as в сочетании с индексированным доступом A[Key] также дает подсказку, что элементы объединения являются интерфейсами, а не примитивными типами, такими как undefined или null.

Наконец, если A[Key] не существует в элементе, мы не будем включать его в вывод. Мы не так строги с Value, если A[Value] не существует, мы представим это как never.

Окончательный код

type UnionToKeyValue<
  T extends { [key: string]: any },
  K extends string
> = K extends `${infer Key}:${infer Value}`
  ? Key extends keyof T
    ? { [A in T as A[Key]]: Value extends keyof A ? A[Value] : never }
    : never
  : never;
Вход в полноэкранный режим Выйти из полноэкранного режима

На этом большая часть тяжелой работы выполнена!

Значения как обратные вызовы

Переход от интерфейсов к интерфейсам должен быть немного проще, давайте посмотрим.

type ValuesAsCallbacks<T extends { [key: string]: any }> = ...  
Вход в полноэкранный режим Выход из полноэкранного режима

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

... = {
  [K in keyof T]: T[K] extends never  
  ? () => void 
  : (payload: T[K]) => void;  
};
Вход в полноэкранный режим Выйти из полноэкранного режима

(2) T — это интерфейс, мы перебираем все ключи заданного типа, представленные как K. Теперь мы проверяем, имеет ли значение T[K] тип never.

(3) Если мы обнаруживаем, что значение имеет тип never, мы возвращаем тип, представляющий обратный вызов, не принимающий никаких параметров.

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

Конечный код

type ValuesAsCallbacks<T extends { [key: string]: any }> = {
  [K in keyof T]: T[K] extends never 
  ? () => void 
  : (payload: T[K]) => void;
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Вот и все, последний недостающий кусочек в головоломке превращения объединения интерфейсов в интерфейс с обратными вызовами 🎉 У нас получилось!

Полный код

Полный листинг кода из этого поста можно найти по этой ссылке:

Многоразовые типы из этого поста в TypeScript playground ⤴

Заключительные слова

Теперь, когда вы изучили один подход, я бы посоветовал вам попробовать другие подходы, например, разделить UnionAsKeyValue<T, K> на две отдельные утилиты и посмотреть, что получится.

Удачи!

Оцените статью
Procodings.ru
Добавить комментарий