Бесплатный курс по TypeScript. Зарегистрируйтесь для отслеживания прогресса →

TypeScript: Введение в дженерики

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

Представьте себе функцию слияния двух массивов. На JavaScript этот код записывается достаточно просто:

const merge = (coll1, coll2) => {
  const result = [];
  result.push(...coll1);
  result.push(...coll2);
  return result;
};

merge([1, 2], [3, 4]); // [1, 2, 3, 4]
merge(['one', 'two'], ['three']); // ['one', 'two', 'three']

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

function merge(coll1: number[], coll2: number[]): number[] {
  const result = [];
  result.push(...coll1);
  result.push(...coll2);
  return result;
}

merge([1, 2], [3, 4]); // [1, 2, 3, 4]

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

function merge(coll1: number[], coll2: number[]): number[];
function merge(coll1: string[], coll2: string[]): string[];

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

Эта ситуация настолько распространенная и не простая, что для нее создана целая подсистема в системе типов называемая дженериками. Дженерики, в применении к функциям, это функции, которые имеют одинаковую логику обработки для разных типов данных. Иногда такие функции называют обобщенными функциями. Ниже пример реализации функции merge() в обобщенном виде:

// или так
// function merge<T>(coll1: T[], coll2: T[]): T[]
function merge<T>(coll1: Array<T>, coll2: Array<T>): Array<T> {
  // Тело функции не поменялось!
  const result = [];
  result.push(...coll1);
  result.push(...coll2);
  return result;
}

// Работает с массивами любых типов
// Сами массивы должны иметь совпадающий тип
merge([1, 2], [3, 4]); // [1, 2, 3, 4]
merge(['one', 'two'], ['three']); // ['one', 'two', 'three']

Здесь мы видим совсем новый синтаксис, к которому нужно будет привыкнуть. Если не вдаваться в детали, запись в <T> после имени функции говорит о том, что перед нами дженерик, который параметризуется типом T. T - это всего лишь обозначение, мы могли бы использовать любую другую заглавную букву, например, X. Чаще всего вы будете видеть именно T, так как это общепринятая практика.

Что конкретно скрывается под типом, с точки зрения кода дженерика, не важно, будь это какой-то объект, число, строка или булево значение. В вызовах примера выше это строка для первого вызова и число для второго. Точно так же можно было бы сделать вызов с булевыми значениями:

merge([true], [false, false]); // [true, false, false]

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

Осталось разобраться с параметрами и возвращаемым значением, что они обозначают? Запись Array<T> описывает собой обобщенный массив, то есть тоже дженерик, но уже для типа. На месте этого параметра может оказаться любой массив хоть number[], хоть boolean[]. Соответственно в коде функции мы говорим о том, что ожидаем на вход два массива, одного типа, и этот же тип является выходным.

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

function merge<X>(coll1: Array<X>, coll2: Array<X>): Array<X>

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

const result = merge([1, 2], ['wow']); // Error!

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

function first<T>(coll: Array<T>): T | null {
  return coll.length > 0 ? coll[0] : null;
}

first([]); // null
first([3, 2]); // 3
first(['code-basics', 'hexlet']); // code-basics

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

Задание

Реализуйте дженерик last(), который извлекает последний элемент из массива если он есть или null если его нет

last([]); // null
last([3, 2]); // 2
last(['code-basics', 'hexlet']); // hexlet
Упражнение не проходит проверку — что делать? 😶

Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:

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

Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.

Мой код отличается от решения учителя 🤔

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

В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.

Прочитал урок — ничего не понятно 🙄

Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.

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

Полезное

Определения

  • Обобщенный тип — абстрактные типы на место которых можно подставить какой-то конкретный тип. Когда мы пишем код, мы можем описать поведение обобщённых типов или их связь с другими обобщёнными типами, не зная какой тип будет использован в месте их использования.


Нашли ошибку? Есть что добавить? Пулреквесты приветствуются https://github.com/hexlet-basics
Если вы столкнулись с трудностями и не знаете, что делать, задайте вопрос в нашем большом и дружном сообществе