Завдання

Реалізуйте типізовану версію Array.join. Join<T, U> приймає масив T, роздільник U і повертає масив T об’єднаний через U.

type Res = Join<["a", "p", "p", "l", "e"], "-">; // expected to be 'a-p-p-l-e'
type Res1 = Join<["Hello", "World"], " ">; // expected to be 'Hello World'
type Res2 = Join<["2", "2", "2"], 1>; // expected to be '21212'
type Res3 = Join<["o"], "u">; // expected to be 'o'

Розв’язок

На перший погляд, найпростіше рішення - перерахувати елементи у кортежі та повернути рядковий тип-літерал з його вмістом і роздільником.

Почнемо з порожнього типу, який нам потрібно реалізувати:

type Join<T, U> = any;

Класичний трюк для перерахування кортежу полягає в тому, щоб вивести його перший елемент і залишок, й потім застосувати рекурсію. Спершу додамо виведення:

type Join<T, U> = T extends [infer S, ...infer R] ? never : never;

Тут ми виводимо рядок (S) і залишок (R) кортежу. Що нам робити з виведеним рядком? Нам потрібно додати після нього роздільник з тип-параметра U:

type Join<T, U> = T extends [infer S, ...infer R] ? `${S}${U}` : never;

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

type Join<T, U> = T extends [infer S, ...infer R]
  ? `${S}${U}${Join<R, U>}`
  : never;

Однак є відсутній випадок, коли елементів більше немає. У такому випадку ми повертаємо порожній рядок, щоб він не переплутав щось у результаті:

type Join<T, U> = T extends [infer S, ...infer R]
  ? `${S}${U}${Join<R, U>}`
  : "";

Здається, це робоче рішення, але ми отримали деякі помилки компілятора. Тож давайте виправимо їх насамперед. Перша помилка компілятора: “Type ‘S’ is not assignable to type ‘string | number | bigint | boolean | null | undefined’”. Така ж помилка стосується параметра типу U. Ми можемо це виправити, ввівши обмеження над дженериками:

type Join<T extends string[], U extends string | number> = T extends [
  infer S,
  ...infer R,
]
  ? `${S}${U}${Join<R, U>}`
  : "";

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

type Join<T extends string[], U extends string | number> = T extends [
  infer S extends string,
  ...infer R extends string[],
]
  ? `${S}${U}${Join<R, U>}`
  : "";

Досить близько, але все одно ні… Ми отримали рішення, яке додає тире в кінці, яке нам тут не потрібно. Наприклад, передавши apple ми отримаємо:

type R0 = Join<["a", "p", "p", "l", "e"], "-">;
// type R0 = "a-p-p-l-e-"

Як його звідти прибрати? Давайте спробуємо перевірити, чи залишилися рядки чи ні, замість того щоб просто додавати роздільник:

type Join<T extends string[], U extends string | number> = T extends [
  infer S extends string,
  ...infer R extends string[],
]
  ? `${S}${R["length"] extends 0 ? never : never}${Join<R, U>}`
  : "";

Ми робимо це, дивлячись на length кортежу R, того самого кортежу, де зберігається залишок. Якщо залишок порожній, це означає, що нічого не залишилося для обробки, тому тут нам роздільник не потрібен:

type Join<T extends string[], U extends string | number> = T extends [
  infer S extends string,
  ...infer R extends string[],
]
  ? `${S}${R["length"] extends 0 ? "" : never}${Join<R, U>}`
  : "";

У всіх інших випадках ставимо роздільник:

type Join<T extends string[], U extends string | number> = T extends [
  infer S extends string,
  ...infer R extends string[],
]
  ? `${S}${R["length"] extends 0 ? "" : U}${Join<R, U>}`
  : "";

Посилання