Обобщения: возможность абстрагировать типы
Реализация дженериков в Typescript дает нам возможность передавать компоненту целый ряд типов, добавляя дополнительный уровень абстракции и повторного использования в ваш код. Обобщения могут быть применены к функциям, интерфейсам и классам в Typescript.
В этом докладе будет объяснено, что такое дженерики и как их можно использовать для этих элементов, попутно рассмотрев ряд жизнеспособных вариантов использования для дальнейшего абстрагирования вашего кода.
Привет, мир дженериков
Чтобы простыми словами продемонстрировать идею, лежащую в основе дженериков, рассмотрим следующую функцию identity(), которая просто принимает один аргумент и возвращает его:
function identity(arg: **number**): **number** {
return arg;
}
Цель нашей функции идентификации - просто вернуть аргумент, который мы передаем. Проблема здесь в том, что мы присваиваем числовой тип как аргументу, так и возвращаемому типу, делая функцию пригодной только для этого примитивного типа — функция не очень расширяема или универсальна, как нам бы хотелось.
Мы действительно могли бы заменить number на любой, но в процессе мы теряем возможность определять, какой тип должен быть возвращен, и в процессе отключаем компилятор.
Что нам действительно нужно, так это identity() для работы с любым конкретным типом, и использование дженериков может это исправить. Ниже приведена та же функция, на этот раз с включенной переменной типа:
function identity**<T>**(arg: **T**): **T** {
return arg;
}
После имени функции мы включили переменную типа T в угловых скобках <>.T теперь является заполнителем для типа, который мы хотим передать в identity, и присваивается arg вместо его типа: вместо числа T теперь выступает в качестве типа.
Примечание: Переменные типа также называются параметрами типа и универсальными параметрами. В этой статье используется термин “переменные типа”, совпадающий с официальной документацией по Typescript.
T расшифровывается как Type и обычно используется в качестве имени переменной первого типа при определении обобщенных значений. Но на самом деле T может быть заменено любым допустимым именем. Мало того, мы не ограничены только одной переменной типа — мы можем ввести любое количество, которое пожелаем определить. Давайте введем U рядом с T и расширим нашу функцию:
function identities**<T, U>**(arg1: **T**, arg2: **U**): **T** {
return arg1;
}
Теперь у нас есть функция identities(), которая поддерживает два универсальных типа, с добавлением переменной типа U, но возвращаемый тип остается T. Теперь наша функция достаточно умна, чтобы принимать два типа и возвращать тот же тип, что и наш параметр arg1.
Но что, если бы мы захотели вернуть объект с обоими типами? Есть несколько способов, которыми мы можем это сделать. Мы могли бы сделать это с кортежем, предоставив наши универсальные типы кортежу следующим образом:
function identities<T, U> (arg1: T, arg2: U): **[T, U]** {
return **[arg1, arg2]**;
}
Наша функция identities теперь умеет возвращать кортеж, состоящий из аргумента T и аргумента U. Однако вы, скорее всего, захотите в своем коде предоставить определенный интерфейс вместо кортежа, чтобы сделать ваш код более читабельным.
Generic Interfaces
Это подводит нас к универсальным интерфейсам; давайте создадим универсальный интерфейс Identities для использования с identities():
interface Identities**<V, W>** {
id1: **V**,
id2: **W**
}
Я использовал здесь V и W в качестве наших переменных типа, чтобы продемонстрировать, что любая буква (или комбинация допустимых буквенно—цифровых имен) являются допустимыми типами - нет никакого значения в том, как вы их называете, кроме как для обычных целей.
Теперь мы можем применить этот интерфейс в качестве возвращаемого типа identities(), изменив наш возвращаемый тип, чтобы соответствовать ему. Давайте тоже утешим.запишите аргументы и их типы для получения дополнительных разъяснений:
function identities<T, U> (arg1: T, arg2: U): **Identities<T, U>** {
console.log(arg1 + ": " + typeof (arg1));
console.log(arg2 + ": " + typeof (arg2));
let identities: **Identities<T, U>** = {
id1: arg1,
id2: arg2
};
return identities;
}
Что мы сейчас делаем с identities(), так это передаем типы T и U в нашу функцию и интерфейс Identities, что позволяет нам определять возвращаемые типы по отношению к типам аргументов.
Примечание: Если вы скомпилируете свой проект Typescript и поищете свои дженерики, вы их не найдете. Поскольку дженерики не поддерживаются в Javascript, вы не увидите их в сборке, сгенерированной вашим транспилятором. Дженерики - это чисто система безопасности разработки во время компиляции, которая обеспечит типобезопасную абстракцию вашего кода.
Generic Classes
Мы также можем сделать класс универсальным в смысле свойств и методов класса. Универсальный класс гарантирует, что указанные типы данных будут использоваться последовательно во всем классе. Например, вы, возможно, заметили следующее соглашение, используемое в проектах React Typescript:
type Props = {
className?: string
...
};type State = {
submitted?: bool
...
};class MyComponent extends React.Component**<Props, State>** {
...
}
Здесь мы используем дженерики с компонентами React, чтобы гарантировать типобезопасность реквизитов и состояния компонента.
Общий синтаксис класса аналогичен тому, что мы изучали до сих пор. Рассмотрим следующий класс, который может обрабатывать несколько типов для профиля программиста:
class Programmer**<T>** {
private languageName: string;
private languageInfo: **T**;constructor(lang: string) {
this.languageName = lang;
}
...
} let programmer1 =
new Programmer**<Language.Typescript>**("Typescript");let programmer2 =
new Programmer**<Language.Rust>**("Rust");
Для нашего класса Programmer T - это переменная типа для метаданных языка программирования, позволяющая нам назначать различные языковые типы свойству languageInfo. Каждый язык неизбежно будет иметь разные метаданные и, следовательно, нуждаться в другом типе.
Примечание о выводе аргумента типа
В приведенном выше примере мы использовали угловые скобки с определенным типом языка при создании экземпляра нового программиста со следующим синтаксическим шаблоном:
let myObj = new className**<Type>**("args");
При создании экземпляров классов компилятор мало что может сделать, чтобы угадать, какой языковой тип мы хотим присвоить нашему программисту; здесь обязательно передавать тип. Однако с помощью функций компилятор может угадать, к какому типу мы хотим отнести наши дженерики — и это наиболее распространенный способ, которым разработчики предпочитают использовать дженерики.
Чтобы прояснить это, давайте снова обратимся к нашей функции identities(). Вызов функции подобным образом присвоит типам string и number значения T и U соответственно:
let result = identities**<string, number>**("argument 1", 100);
Однако чаще всего компилятор автоматически распознает эти типы, что приводит к более чистому коду. Мы могли бы полностью опустить угловые скобки и просто написать следующее утверждение:
let result = identities("argument 1", 100);
Компилятор здесь достаточно умен, чтобы определить типы наших аргументов и присвоить им значения T и U без необходимости разработчику явно определять их.
Предостережение: Если бы у нас был общий возвращаемый тип, с которым не вводились никакие аргументы, компилятору потребовалось бы, чтобы мы явно определили типы.
Когда следует использовать дженерики
Обобщения дают нам большую гибкость для присвоения данных элементам типобезопасным способом, но их не следует использовать, если такая абстракция не имеет смысла, то есть при упрощении или минимизации кода, где можно использовать несколько типов.
Жизнеспособные варианты использования дженериков невелики; вы часто найдете подходящий вариант использования в своей кодовой базе здесь и там, чтобы избежать повторения кода, но в целом есть два критерия, которым мы должны соответствовать при принятии решения об использовании дженериков.:
- Когда ваша функция, интерфейс или класс будут работать с различными типами данных
- Когда ваша функция, интерфейс или класс использует этот тип данных в нескольких местах
Вполне может случиться так, что у вас не будет компонента, который оправдывал бы использование дженериков на ранней стадии проекта. Но по мере роста проекта возможности компонента часто расширяются. Эта дополнительная расширяемость вполне может в конечном итоге соответствовать двум вышеуказанным критериям, и в этом случае введение универсальных функций было бы более чистой альтернативой, чем дублирование компонентов только для удовлетворения диапазона типов данных.
Далее в статье мы рассмотрим больше вариантов использования, в которых оба эти критерия удовлетворяются. Давайте рассмотрим некоторые другие функции, предлагаемые generics Typescript, прежде чем делать это.
Общие ограничения
Иногда мы можем захотеть ограничить количество типов, которые мы принимаем, с помощью каждой переменной типа — и, как следует из названия, это именно то, что делают общие ограничения. Мы можем использовать ограничения несколькими способами, которые сейчас рассмотрим.
Использование ограничений для обеспечения существования свойств типа
Иногда для универсального типа требуется, чтобы у этого типа существовали определенные свойства. Мало того, компилятор не будет знать о существовании определенных свойств, если мы явно не определим их для переменных типа.
Хорошим примером этого является работа со строками или массивами, где мы предполагаем, что .свойство length доступно для использования. Давайте снова воспользуемся нашей функцией identity() и попытаемся записать длину аргумента в журнал:
// this will cause an errorfunction identity**<T>**(arg: **T**): **T** {
console.log(**arg.length**);
return arg;
}
В этом сценарии компилятор не будет знать, что T действительно имеет a .свойство длины, особенно учитывая, что T может быть присвоен любой тип. Что нам нужно сделать, так это расширить нашу переменную type до интерфейса, в котором содержатся наши требуемые свойства. Это выглядит примерно так:
interface Length {
length: number;
}
function identity**<T extends Length>**(arg: **T**): **T** {
// length property can now be called
console.log(arg.length);
return arg;
}
T ограничено с помощью ключевого слова extends, за которым следует тип, который мы расширяем, в угловых скобках. По сути, мы сообщаем компилятору, что мы можем поддерживать любой тип, который реализует свойства в пределах длины.
Теперь компилятор сообщит нам, когда мы вызовем функцию с типом, который не поддерживается .длина. Не только это, .length теперь распознается и может использоваться с типами, реализующими это свойство.
Примечание: Мы также можем расширять из нескольких типов, разделяя наши ограничения запятой. Например. <T расширяет длину, Type2, Type3>.
Явно поддерживающие массивы
Действительно, есть другое решение этой проблемы.проблема со свойством длины, если бы мы явно поддерживали тип массива. Мы могли бы определить наши переменные типа как массив, например, так:
// length is now recognised by declaring T as a type of arrayfunction identity<**T**>(arg: **T[]**): **T[]** {
console.log(arg.length);
return arg;
}//orfunction identity<**T**>(arg: **Array<T>**): **Array**<**T**> {
console.log(arg.length);
return arg;
}
Обе вышеприведенные реализации будут работать, посредством чего мы сообщим компилятору, что arg и возвращаемый тип функции будут типом массива.
Использование ограничений для проверки существования ключа объекта
Отличным вариантом использования ограничений является проверка того, что ключ существует в объекте, с помощью другого фрагмента синтаксиса: extends keyof. В следующем примере проверяется, существует ли ключ для объекта, который мы передаем в нашу функцию:
function getProperty<**T,** **K** **extends keyof** **T**>(obj: **T**, key: **K**): **T[K]** {
return obj[key];
}
Первый аргумент - это объект, из которого мы берем значение, а второй - ключ к этому значению. Возвращаемый тип описывает эту связь с помощью T[K], хотя эта функция также будет работать без определенного возвращаемого типа.
Что здесь делают наши дженерики, так это гарантируют, что ключ нашего объекта существует, чтобы не возникало ошибок во время выполнения. Это типобезопасное решение, основанное на простом вызове чего-то вроде let value = obj[key];.
Отсюда просто вызвать функцию getProperty, как это сделано в следующем примере, чтобы получить свойство из объекта typescript_info:
**// the property we will get will be of type Difficulty**enum Difficulty {
Easy,
Intermediate,
Hard
}**// defining the object we will get a property from**let typescript_info = {
name: "Typescript",
superset_of: "Javascript",
difficulty: Difficulty.Intermediate,
}**// calling getProperty to retrieve a value from typescript_info**let superset_of: **Difficulty** =
getProperty(typescript_info, 'difficulty');
В этом примере также используется перечисление для определения типа свойства сложности, которое мы получили с помощью getProperty.
Более общие варианты использования
Далее, давайте рассмотрим, как дженерики могут быть использованы в более целостных примерах использования в реальном мире.
Сервисы API
API-сервисы являются отличным вариантом использования дженериков, позволяя вам объединять ваши обработчики API в один класс и присваивать правильный тип при извлечении результатов из различных конечных точек.
Возьмем, к примеру, метод GetRecord() — класс не знает, какой тип записи мы извлекаем из нашей службы API, и не знает, какие данные мы будем запрашивать. Чтобы исправить это, мы можем ввести обобщения в GetRecord() в качестве заполнителей для возвращаемого типа и типа нашего запроса:
class APIService extends API {
public getRecord**<T, U>** (**endpoint: string, params: T[]**): **U** {}
public getRecords**<T, U>** (**endpoint: string, params: T[]**): **U[]** {} ...
}
Наш универсальный метод теперь может принимать параметры любого типа, которые будут использоваться для запроса конечной точки API. U - это наш возвращаемый тип.
Манипулирование массивами
Дженерики позволяют нам манипулировать типизированными массивами. Возможно, мы захотим добавить или удалить элементы из базы данных сотрудников, как, например, в следующем примере, в котором используется универсальная переменная для класса Department и метод add():
class Department**<T>** {
//different types of employees
private employees:**Array<T>** = new **Array<T>**();
public add(employee: **T**): void {
this.employees.push(employee);
} ...
}
Вышеуказанный класс позволяет нам управлять сотрудниками по отделам, позволяя каждому отделу и сотрудникам внутри него быть определенными по одному определенному типу.
Или, возможно, вам требуется более универсальная служебная функция для преобразования массива в строку, разделенную запятыми:
function arrayAsString<T>(names:T[]): string {
return names.join(", ");
}
Дженерики позволят этим типам служебных функций стать типобезопасными, избегая использования любого типа в процессе.
Расширение с помощью классов
Мы видели, что общие ограничения используются с компонентами класса React для ограничения реквизитов и состояния, но они также могут быть использованы для обеспечения правильного форматирования свойств класса. Возьмем следующий пример, который гарантирует, что имя и фамилия программиста определены, когда они требуются функции:
class Programmer {
// automatic constructor parameter assignment
constructor(public fname: string, public lname: string) {
}
}
function logProgrammer**<T extends Programmer>**(prog: **T**): void {
console.log(`${ prog.fname} ${prog.lname}` );
}
const programmer = new Programmer("Ross", "Bulat");
logProgrammer(programmer); // > Ross Bulat
Примечание: Конструктор здесь использует автоматическое назначение параметров конструктора, функцию Typescript, которая присваивает свойства классу непосредственно из аргументов конструктора.
Такая настройка повышает надежность и целостность ваших объектов. Если ваши программные объекты должны использоваться с запросом API, в котором вам требуется учитывать определенные поля, общие ограничения гарантируют, что все они будут присутствовать во время компиляции.
Подводя итог
Чтобы узнать больше о дженериках, в официальных документах Typescript есть актуальные ссылки на них, и они охватывают более продвинутые варианты использования дженериков.
Узнайте больше об общих возможностях работы с динамическими объектами и подмножествами объектов:
TypeScript: Typing Dynamic Objects and Subsets with Generics
Узнайте, как обобщенные выражения используются с условными операторами в TypeScript:
TypeScript: Conditional Types Explained
Я также опубликовал статью, в которой описывается, как дженерики могут быть использованы в реальном сценарии использования при реализации API service manager. Примените знания из этой статьи к этому практическому варианту использования, с проектом, также доступным на Github: Advanced Typescript на примере: API Service Manager.
Я также задокументировал решение для живого чата Typescript, серию из двух частей, в которой описывается настройка сервера Typescript Express, а также клиент React на основе Typescript: Typescript Live Chat: Express и Socket.io Настройка сервера.
Это был краткий обзор дженериков в Typescript с целью внести ясность в то, что они собой представляют и как их можно использовать. Я надеюсь, что к настоящему времени у вас есть какие-то идеи о том, как их каким-то образом реализовать в ваших проектах.
Критерии до внедрения
Обобщения могут быть полезны при определенных обстоятельствах для дальнейшего абстрагирования и минимизации вашего кода. Ознакомьтесь с двумя критериями, упомянутыми выше, прежде чем внедрять дженерики — иногда лучше не учитывать дополнительную сложность там, где это не оправдано.