Завдання

Реалізувати тип ReplaceKeys, який замінить ключі в об’єднаннях. Якщо якогось ключа немає в елементі, просто пропускаємо. Такий тип приймає три аргументи. Наприклад:

type NodeA = {
  type: "A";
  name: string;
  flag: number;
};

type NodeB = {
  type: "B";
  id: number;
  flag: number;
};

type NodeC = {
  type: "C";
  name: string;
  flag: number;
};

type Nodes = NodeA | NodeB | NodeC;

// would replace name from string to number, replace flag from number to string
type ReplacedNodes = ReplaceKeys<
  Nodes,
  "name" | "flag",
  { name: number; flag: string }
>;

// would replace name to never
type ReplacedNotExistKeys = ReplaceKeys<Nodes, "name", { aa: number }>;

Розв’язок

У нас на руках об’єднання інтерфейсів й нам потрібно по них ітеруватися. В процесі ітерації, потрібно замінити ключі на необхідні, передані через параметри. Дистрибутивність тут, очевидно, дуже допоможе, як і типи зіставлення.

Я почну з такого невеликого факту, як “типи зіставлення в TypeScript також дистрибутивні”. Тобто, якщо ви напишете тип зіставлення, який приймає об’єднання - поведінка буде дистрибутивною. Таким чином, ми зможемо ітеруватися як по об’єднаннях, так і по ключах самого інтерфейсу одночасно. Давайте спробую пояснити.

Ви вже напевно знаєте, що умовні типи дистрибутивні. Така властивість мови нам дуже часто допомагала в завданнях раніше. Кожного разу, коли ми писали U extends any ? U[] : never, ми отримували всередині правдивої гілки один елемент з всього об’єднання на кожній ітерації. Компілятор робить це за нас.

Аналогічна властивість присутня і в типах зіставлення. Ми можемо написати тип зіставленні і, якщо на вході об’єднання, компілятор буде неявно застосовувати дистрибутивність. Відповідно, ми будемо працювати з окремим елементом із об’єднання.

Так що давайте почнемо з простого. Візьмемо кожен елемент із U (дякую дистрибутивності) і на кожному елементі візьмемо список ключів, разом з його типами значень.

type ReplaceKeys<U, T, Y> = { [P in keyof U]: U[P] };

Таким чином, ми скопіювали один в один все, що прийшло через параметр U. Тепер, нам потрібно застосувати фільтр й працювати тільки з тими ключами, які є в T і Y.

Для початку перевіримо, чи є поточна властивість в параметрі T (в списку ключів, які нам потрібно оновити).

type ReplaceKeys<U, T, Y> = {
  [P in keyof U]: P extends T ? never : never;
};

Якщо це так, то це означає що розробник попросив нас змінити вказаний ключ. І, скоріше за все, він вказав і тип, на який потрібно провести заміну. Але, ми не можемо бути впевненими в цьому. Тому, додатково перевіримо, що ця властивість присутня в Y.

type ReplaceKeys<U, T, Y> = {
  [P in keyof U]: P extends T ? (P extends keyof Y ? never : never) : never;
};

І тільки в ситуації, коли обидві умови правдиві, ми можемо бути впевненими в необхідності заміни. Беремо тип із Y й проводимо заміну.

type ReplaceKeys<U, T, Y> = {
  [P in keyof U]: P extends T ? (P extends keyof Y ? Y[P] : never) : never;
};

Проте, якщо виявиться, що такого ключа немає у Y, але він є в T, нам потрібно повернути never (відповідно до завдання). Залишається останній варіант, коли обидві умови неправдиві. В такому випадку, ми просто повертаємо той же тип, який був в оригінальному інтерфейсі.

type ReplaceKeys<U, T, Y> = {
  [P in keyof U]: P extends T ? (P extends keyof Y ? Y[P] : never) : U[P];
};

Маючи дистрибутивні типи зіставлення, ми змогли реалізувати дуже зрозуміле рішення. Без них, ми були б змушені використовувати дистрибутивні умовні типи з подальшим застосуванням типів зіставлення всередині умовного типу. Що, очевидно, не так добре виглядає, як це рішення.

Посилання