Дженерики в TypeScript: разбираемся вместе +9


Всем привет! Команда TestMace публикует очередной перевод статьи из мира web-разработки. На этот раз для новичков! Приятного чтения.


Развеем пелену таинственности и недопонимания над синтаксисом <T> и наконец подружимся с ним



Наверное, только матёрые разработчики Java или других строго типизированных языков не хлопают глазами, увидев дженерик в TypeScript. Его синтаксис коренным образом отличается от всего того, что мы привыкли видеть в JavaScript, поэтому так непросто сходу догадаться, что он вообще делает.


Я бы хотел показать вам, что на самом деле всё гораздо проще, чем кажется. Я докажу, что если вы способны реализовать на JavaScript функцию с аргументами, то вы сможете использовать дженерики без лишних усилий. Поехали!


Дженерики в TypeScript


В документации TypeScript приводится следующее определение: "дженерики — это возможность создавать компоненты, работающие не только с одним, а с несколькими типами данных".


Здорово! Значит, основная идея состоит в том, что дженерики позволяют нам создавать некие повторно используемые компоненты, работающие с различными типами передаваемых им данных. Но как это возможно? Вот что я думаю.


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


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


identity.js
function identity (value) {
    return value;
}

console.log(identity(1)) // 1

Сделаем так, чтобы она работала с числами:


identity.ts
function identity (value: Number) : Number {
    return value;
}

console.log(identity(1)) // 1

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


genericIdentity.ts
function identity <T>(value: T) : T {
    return value;
}

console.log(identity<Number>(1)) // 1

Ох уж этот странный синтаксис <T>! Отставить панику. Мы всего лишь передаём тип, который хотим использовать для конкретного вызова функции.



Посмотрите на картинку выше. Когда вы вызываете identity<Number>(1), тип Number — это такой же аргумент, как и 1. Он подставляется везде вместо T. Функция может принимать несколько типов аналогично тому, как она принимает несколько аргументов.



Посмотрите на вызов функции. Теперь-то синтаксис дженериков не должен вас пугать. T и U — это просто имена переменных, которые вы назначаете сами. При вызове функции вместо них указываются типы, с которыми будет работать данная функция.


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



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


Обратите особое внимание на второй вызов console.log на анимации выше — в него не передаётся тип. В этом случае TypeScript попытается вычислить тип по переданным данным.


Обобщённые классы и интерфейсы


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


Посмотрите на пример и попробуйте разобраться сами. Надеюсь, у вас получилось.


genericClass.ts
interface GenericInterface<U> {
  value: U
  getIdentity: () => U
}

class IdentityClass<T> implements GenericInterface<T> {
  value: T

  constructor(value: T) {
    this.value = value
  }

  getIdentity () : T {
    return this.value
  }

}

const myNumberClass = new IdentityClass<Number>(1)
console.log(myNumberClass.getIdentity()) // 1

const myStringClass = new IdentityClass<string>("Hello!")
console.log(myStringClass.getIdentity()) // Hello!

Если код сразу не понятен, попробуйте отследить значения type сверху вниз вплоть до вызовов функции. Порядок действий следующий:


  1. Создаётся новый экземпляр класса IdentityClass, и в него передаются тип Number и значение 1.
  2. В классе значению T присваивается тип Number.
  3. IdentityClass реализует GenericInterface<T>, и нам известно, что T — это Number, а такая запись эквивалентна записи GenericInterface<Number>.
  4. В GenericInterface дженерик U становится Number. В данном примере я намеренно использовал разные имена переменных, чтобы показать, что значение типа переходит вверх по цепочке, а имя переменной не имеет никакого значения.

Реальные случаи использования: выходим за рамки примитивных типов


Во всех приведённых выше вставках кода были использованы примитивные типы вроде Number и string. Для примеров самое то, но на практике вы вряд ли станете использовать дженерики для примитивных типов. Дженерики будут по-настоящему полезны при работе с произвольными типами или классами, формирующими дерево наследования.


Рассмотрим классический пример наследования. Допустим, у нас есть класс Car, являющийся основой классов Truck и Vespa. Пропишем служебную функцию washCar, принимающую обобщённый экземпляр Car и возвращающую его же.


car.ts
class Car {
  label: string = 'Generic Car'
  numWheels: Number = 4
  horn() {
    return "beep beep!"
  }
}

class Truck extends Car {
  label = 'Truck'
  numWheels = 18
}

class Vespa extends Car {
  label = 'Vespa'
  numWheels = 2
}

function washCar <T extends Car> (car: T) : T {
  console.log(`Received a ${car.label} in the car wash.`)
  console.log(`Cleaning all ${car.numWheels} tires.`)
  console.log('Beeping horn -', car.horn())
  console.log('Returning your car now')
  return car
}

const myVespa = new Vespa()
washCar<Vespa>(myVespa)

const myTruck = new Truck()
washCar<Truck>(myTruck)

Сообщая функции washCar, что T extends Car, мы обозначаем, какие функции и свойства можем использовать внутри этой функции. Дженерик также позволяет возвращать данные указанного типа вместо обычного Car.


Результатом выполнения данного кода будет:


Received a Vespa in the car wash.
Cleaning all 2 tires.
Beeping horn - beep beep!
Returning your car now
Received a Truck in the car wash.
Cleaning all 18 tires.
Beeping horn - beep beep!
Returning your car now

Подведем итоги


Надеюсь, я помог вам разобраться с дженериками. Запомните, всё, что вам нужно сделать, — это всего лишь передать значение type в функцию :)


Если хотите ещё почитать про дженерики, я прикрепил далее пару ссылок.


Что почитать:



Наша команда создает крутой инструмент TestMace — мощная IDE для работы с API. Создавайте сценарии, тестируйте эндпоинты и пользуйтесь всей мощью продвинутого автодополнения и подсветки синтаксиса. Пишите нам! Мы тут: Telegram, Slack, Facebook, Vk




К сожалению, не доступен сервер mySQL