Обобщения (англ. generics) или дженерики - это инструмент, который позволяет писать на TypeScript компоненты, способные работать с различными типами данных. В то же время они позволяют сохранить строгость кода и работоспособность проверки типов.
Я знакомился с дженериками, когда изучал Java. Но с изучением их в TypeScript у меня возникли некоторые сложности. Сперва мне показалось, что они, по сути, являются лишними и тот же функционал можно реализовать, используя обычное указание типов. Так, например, в Java дженерики используются для следующего:
List <Person> persons;
Тут мы создаем список, который может хранить только объекты Person. Запихнуть в него что-то другое не выйдет. И, зная это, с элементами списка можно спокойно работать как с персонами (вызывать соответствующие методы). Но в TypeScript подобное мы можем сделать следующим образом:
let persons: Person[];
Так зачем же нам тогда нужна эта конструкция из угловых скобок? Документация и статьи по теме обобщений дают какие-то примеры, притянутые за уши. Поэтому попробую здесь изложить свое понимание дженериков в TypeScript.
Начнем с простого примера - javascript функции "эхо", которая просто возвращает переданное в нее значение.
function echo (arg) {
return arg;
}
Принимает на вход значение любого типа и его же возвращает. Все хорошо, но мы же изучаем TypeScript. Поэтому нужно добавить типизацию:
function echo (arg:number): number {
return arg;
}
Теперь функция принимает на вход числа и их же возвращает. Но предположим, что нам надо, чтобы наша функция умела работать и со строками:
echo('Ау'); // Ошибка: тип 'string' не подходит
Пишем другую функцию и описываем ее под работу со строками:
function echo2 (arg:string): string {
return arg;
}
echo2('Ау');
echo2(111); // Ошибка: тип 'number' не подходит
Не очень изящное решение...
Хорошо, тогда можно же описать нашу функцию под тип any
:
function echo (arg:any): any {
return arg;
}
echo('Ау');
echo(111);
Требуемая задача выполнена. Наша функция теперь работает и с числами, и со строками. Но тут есть два нюанса. Первый - функция также может принимать и все остальные типы, кроме необходимых. Это не всегда допустимо. А второй нюанс заключается в том, что использовав описание any
мы теряем способность TypeScript правильно проверять типы на этапе компиляции. Давайте создадим тестовую функцию, чтобы это увидеть:
function test(params: string) { };
Она может принимать параметры только строкового типа. Попробуем дать ей на вход результаты работы нашей функции echo
с различными значениями:
test(echo('Ау'));
test(echo(1));
В первом случае мы используем строку в echo
. Она вернет нам тоже строку, которая попадет в тестовую функцию. Тут все хорошо. Но вот во второй строке мы в эхо отправляем число. Эхо его возвращает, и это число попадает на вход тестовой функции, которая с числами работать не умеет ну совсем никак. А компилятор TypeScript молчит как партизан. Он не видит здесь никаких ошибок. А это может привести уже к куда более серьезной проблеме - ошибке выполнения.
Вот тут на помощь и приходят обобщения:
function echo <T>(arg:T): T {
return arg;
}
Здесь в угловых скобках мы указываем имя типовой переменной, которую в дальнейшем мы будем использовать для указания типа наших данных. Далее мы указываем, что наша функция должна принимать параметры типа T
и возвращать должна тоже данные типа T
. Теперь вызываем нашу функцию:
echo <string>('Ау');
echo <number>(1);
В первой строке мы используем строковый тип. Во второй - числовой. Явно тип можно не указывать. TypeScript самостоятельно выведет тип исходя из типа переданного значения:
echo ('Ау');
echo (1);
Теперь воспользуемся нашей тестовой функцией, чтобы посмотреть, работает ли проверка типов:
test(echo('Ау'));
test(echo(1)); // Ошибка подсвечивается. Проверка типа работает
Таким образом мы создали функцию, которая сохранила ту же гибкость, что была при использовании типа any
, но при этом мы сохранили возможность проверки типов компилятором TypeScript.
Тип для обобщенной функции
Описать тип обобщенной функции можно почти так же, как и обычной, только добавляется типовая переменная, как при инициализации:
function echo <T>(arg: T): T {
return arg;
}
let myEcho: <T>(arg: T) => T = echo;
Названия типовых переменных (T) в объявлении и инициализации могут отличаться. Главное, чтобы между ними было согласование.
let myEcho: <T>(arg: T) => T = function <E>(arg: E): E {
return arg;
}
То же самое можно записать, используя объектную форму записи:
let myEcho: { <T>(arg: T):T } = function <E>(arg: E): E {
return arg;
}
А здесь мы уже подобрались к обобщенным интерфейсам.
Обобщенные интерфейсы
Перенесем нашу последнюю объектную запись в интерфейс:
interface IEcho {
<T>(arg: T):T
}
let myEcho: IEcho = function <E>(arg: E): E {
return arg;
}
Мы получили интерфейс для обобщенных функций. Но можно типовую переменную вынести в описание самого интерфейса, сделав ее доступной для всех его членов, и таким образом получить обобщенный интерфейс:
interface IEcho <T> {
(arg: T):T
}
let myEcho: IEcho <string> = function <E>(arg: E): E {
return arg;
}
Обобщенные классы
Очень похоже на описание интерфейса:
class User <T> {
_id: T;
constructor(id: T) {
this._id = id;
}
get id(): T{
return this._id;
}
}
Теперь воспользуемся этим обобщенным классом, создав новых пользователей:
let pavel = new User<number>(13);
let alex = new User<string>('alex');
Но, если объект, обобщенный каким-либо типом, создан, то изменить этот тип уже не выйдет:
pavel = new User<string>('13'); // Ошибка
Ограничения обобщений
Иногда нужно в качестве типового параметра принимать какие-то определенные типы. Можно задать ограничение для обобщения. Предположим, нам нужна функция, которая умела бы работать с объектами, способными летать и плавать. Создадим два интерфейса, которые описывают возможность объектов летать и плавать. А нашу требуемую функцию run
обобщим по типу, наследующему эти интерфейсы <T extends Flyable & Swimmable>
:
interface Flyable {
fly (): void
}
interface Swimmable {
swim (): void
}
function run <T extends Flyable & Swimmable>(arg: T) {
arg.fly();
arg.swim();
arg.go(); // Ошибка: 'go' does not exist on type 'T'
}
Попробуем передать в нашу функцию объект, который ни летать, ни плавать не умеет:
run(pavel); // Ошибка: Property 'fly' is missing in type 'User<number>'.
А теперь передадим утку:
let duck = {
fly() {},
swim() {},
go () {}
}
run(duck); // Все ОК