TypeScript, "надмножество JS", облегчает создание поддерживаемых, понятных и масштабируемых приложений благодаря эффективной возможности проверки типов.

Дженерики играют важную роль в TypeScript, поскольку они позволяют нам писать многократно используемый код, принимающий в качестве аргументов как значения, так и типы.

Дженерики в функциях

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

interface Person {
  name: String
}

const convertStringToArray = (value: String): Array<String> => {
  return [value];
}  


const convertNumberToArray = (value: Number): Array<Number> => {
  return [value];
}

const convertPersonToArray = (value: Person): Array<Person> => {
  return [value];
}  

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

Одна вещь, которую мы бы могли сделать, это поместить тип any, чтобы значения типа String, Number и Person можно было использовать в качестве аргументов в одной и той же функции. К сожалению, это вызывает больше проблем, чем решает (в общем, если вы планируете использовать тип any очень часто, то, возможно, лучше оставить его исключительно в JS).

Решение "проблемы повторного использования" с помощью дженериков — пример:

export interface Person {
  name: String;
}

export const convertToValueArray = <T>(value: T): Array<T> => {
  return [value];
};

const person: Person = {
  name: "Mahesh"
};

const firstPerson = convertToValueArray(person)[0];

Функция converToValueArray получает значение выбранного типа <T> и возвращает массив этого типа: Array<T> . Так, например, если значение имеет тип String, то возвращаемый тип будет Array<String>.

Давайте посмотрим, как TypeScript показывает ошибку, когда мы определяем наш дженерик-тип.

Обратите внимание, в строке 18, после использования дженериков, если мы хотим получить доступ к age (возрасту), он показывает соответствующую ошибку, что нам и нужно для получения своевременного фидбека на любую неточность.

Выведенный тип

Давайте определим функцию, которая принимает дженерик-тип.

function convertToArray<T>(args: T): Array<T> {
  return [args];
}

Мы можем вызвать функцию двумя способами

convertToArray("someString");
convertToArray<String>("someString");

Как мы видим, если тип не передан в <>, то он выводится автоматически. Вывод типа делает код короче, но в сложных определениях может потребоваться явная передача типа.

Более одного дженерик-типа

Точно так же, как аргументы функции, мы можем передавать более одного типа, что не зависит от количества аргументов функции. Например

function doStuff<T, U>(name: T): T {
  // ...some process
  return name;
}

Вышеуказанная функция может быть вызвана следующим образом:

doStuff<String, Number>("someString");

Дженерик-классы

Много раз нам требовалось бы создать дженерик-класс, например, абстрактные Base (базовые) классы. В них мы можем передавать типы при создании экземпляров классов.

interface DatabaseConnector {
  get: Function;
  put: Function;
}

abstract class BaseLocalDatabase<T, M> {
  
  tableName: String;
  databaseInstance: DatabaseConnector;
  
  constructor(tableName: String) {
    this.tableName = tableName;
    this.databaseInstance = getDatabase<T>(tableName);
  }
  
  async insert(data: M): Promise<void> {
    await this.databaseInstance.put(data);
  }

  async get(id: Number): Promise<M> {
    return await this.databaseInstance.get(id);
  }

  abstract getFormattedData(): Array<M>;
}

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

interface ContactTable {
  name: String;
}

interface ContactModel {
  id: String;
  name: String;
  phoneNumber: String;
  profilePicture: String;
  createdAt: Date;
  updatedAt: Date;
}

class ContactLocalDatabase extends BaseLocalDataBase<ContactTable, ContactModel> {
  
  // overriden function 
  getFormattedData(): ContactModel[] {
    // format and return data
  }
  
}
  • В строке 14, при расширении дженерик-класса, в данной базе данных мы должны передать два типа. В нашем случае ContactTable и ContactModel.

  • Строка 17: База данных ContactLocalDatabase получит функции из родительского класса и должна переопределить функцию getFormattedData, поскольку она определена как абстрактная функция в родительском базовом классе.

  • Строка 17: Теперь это функция, имеющая дженерик-тип, о котором мы говорили в первом разделе.

Давайте создадим экземпляр ContactLocalDataBase, чтобы увидеть дженерики класса в действии.

let contactsLocalDatabase = new ContactLocalDatabase("Contact table");

await contactsLocalDatabase.get(21);

const contactModel: ContactTable = {
  id: 12,
  name: 'Some name',
  ...,            // define all other values.
  ...
}
await contactsLocalDatabase.insert(contactModel);

const contactArray: Array<ContactModel> = contactsLocalDatabase.getFormattedData();
  • Строка 1: поскольку мы определили типы класса ContactLocalDatabase при использовании ключевого слова new, типы не нужно передавать в базовый класс.

  • Строки 3, 11, 13: мы можем заметить, что эти функции принадлежат абстрактному классу. Они ведут себя в соответствии с определениями дженерик-класса.

Ограничения дженериков

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

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

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

  • Строка 6: Это вполне допустимая ошибка, так как у данных дженерик-тип. Они могут быть String, Number, Float или любым другим типом, а передаваемые данные могут как учитывать длину, так и нет.

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

  • Строка 4: мы расширили тип T, чтобы он имел свойство length (длина);

  • Строка 5: исчезла ошибка, которая говорила, что свойство length не существует для типа 'T';

  • Строка 10: когда мы вызываем функцию с числом, она выдает ошибку, объясняя, что не выполняется условие ограничения;

  • Строки 12 и 13: когда мы передаем корректные данные, такие как String или Array, TS не выдает ошибку.


Завтра состоится открытое занятие «Обзор технологий для построения API», на котором рассмотрим несколько протоколов клиент-серверных приложений. На примере увидим сильные стороны и недостатки. Регистрация для всех желающих здесь.

Комментарии (3)


  1. aamonster
    31.08.2022 16:30

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


  1. kahi4
    31.08.2022 17:58

    Позанудствую:

    1. По-русски это называется «обобщенное программирование» и «обобщенные типы»

    2. Им можно задавать значение по-умолчанию (через =), считается хорошей практикой «заземлять» все обобщенные типы на unknown, за исключением тривиальных типов, а то ts будет их радостно выводить в any при первой же возможности

    3. «Как мы видим, если тип не передан в <>, то он выводится автоматически. Вывод типа делает код короче, но в сложных определениях может потребоваться явная передача типа.». TS не ленивый и КАКОЙ-ТО тип выведет всегда. Передавать нужно либо для сужения, либо для расширения типа (либо если в цепочке есть рак в виде any). Например:

    const personArray = convertPersonToArray<Person>({ name: foo })
    // personArray: [Person]
    // без явного приведения будет
    // personArray: [{ name: string }]
    // но это совсем простой пример и так же радостно можно сделать обычное приведение типов

    К слову, в таком тривиальном случае можно не указывать возврат функции тоже.

    1. Классы:

    2. В getFormattedData при наследовании можно и не указывать возвращаемый тип, покуда он определен в родителе

    3. Стоит заметить, что у наследника могут быть свои обобщенные типы, но он должен указать все типы у родителя. Т.е.:

    // работает
    class ContactLocalDatabase<T> extends BaseLocalDataBase<ContactTable, T> {}
    
    // уже нет
    class ContactLocalDatabase extends BaseLocalDataBase<ContactTable> {} // not enough generic types
    // заметьте, что если родитель имел бы второй тип со значением по-умолчанию, тогда бы сработало
    1. Не стоит забывать о существовании перегрузки функций и об арифметических типах, часто они гораздо уместнее обобщенных типов.


  1. Surof1n
    02.09.2022 13:50

    Поменяйте в примерах функции конструкторы на примитивные типы: String — string