Бесплатный курс по 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.

Чаще всего мы будем видеть это обозначение, так как это общепринятая практика.

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

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
Если вы столкнулись с трудностями и не знаете, что делать, задайте вопрос в нашем большом и дружном сообществе