Generics
Что такое generic-и и зачем они нужны
Из-за того, что в JavaScript нет строгой типизации, такие конструкции как generic-и обошли его стороной. Generic-и помогают писать переиспользуемые и гибкие компоненты программы (функции, интерфейсы, классы и т.д.), сохраняя при этом строгую типизацию. Для того, чтобы понять цель существования generic-ов, рассмотрим пример.
Необходимо реализовать функцию, которая принимает неограниченное количество параметров и возвращает массив из значений этих параметров. На JavaScript эта функция могла бы выглядеть так:
function createArray(...args) {
return args;
}
const first = createArray(1, 2, 3);
const second = createArray(1, "lalaka", true)
В TypeScript для этой функции необходимо указать типы параметров и, возможно, тип возвращаемого значения. Если посмотреть на использования функции createArray
в JavaScript, то можно сказать, что нам подойдет тип any
.
function createArray(...args: any[]): any[] {
return args;
}
const first: any[] = createArray(1, 2, 3);
const second: any[] = createArray(1, "lalaka", true)
Но что если от функции требуется сохранить знание о том, какой будет тип у элементов создаваемого массива. В текущей реализации, даже если все передаваемые аргументы будут одного типа, тип элементов "потеряется".
const array = createArray(1, "lalaka", true);
const a: number = array[0];
const b: number = array[1]; //ошибки нет, потому что тип элементов массива any
const c: string = array[2];
Чтобы не потерять тип элементов массива, можно при описании функции явно указывать нужный тип. Например, так будет выглядеть функция для number
.
function createArray(...args: number[]): number[] {
return args;
}
const first: number[] = createArray(1, 2, 3);
const second: number[] = createArray(1, "lalaka", true); //Error: Argument of type '"lalaka"' is not assignable to parameter of type 'number'.
const a: number = first[0];
const b: string = first[1]; //Error: Type 'number' is not assignable to type 'string'.
В таком случаи тип элементов не был потерян. Однако теперь функция createArray
может работать только с совместимыми с number
типами. Стоит отметить, что функции createArray
не использует специфичные особенности типа своих аргументов - она не суммирует числа, не вызывает методы строк. То есть эта функция может работать с абстрактным типом. Здесь появляются generic-и.
function createArray<T>(...args: T[]): T[] {
return args;
}
const first: number[] = createArray<number>(1, 2, 3);
const second: string[] = createArray<string>("1", "2", "3");
const third: (number | string)[] = createArray<number | string>(10, "100", 50);
const a: number = first[0];
const b: string = second[0];
T
- называется generic-параметром или generic-ом функции. Это своего рода переменная типа или placeholder для типа, или абстрактный тип. Один из самых простых примеров использование generic-ов - это тип массива. Массиву без разницы, какого типа могут быть его элементы. При этом массив стремиться сохранить специфичность типа элементов, чтобы взяв элемент по индексу, тип не был потерян и TypeScript мог бы дальше проверять совместимость типов. Поэтому существует встроенный интерфейс Array<T>
(ранее про него говорилось) с generic-параметром T
.
Как объявлять и использовать generic-параметры
Generic-параметры можно использовать при объявлении функций (как это было показано ранее). Для этого перед ()
, в которых указываются параметры, нужно внутри <>
указать названия generic-ов.
function foo<T>(): void {
const a: T = ...
}
Для лямбда-функций generic-и объявляются по тому же правилу.
const foo = <T>(): void => {
const a: T = ...
}
Одновременно можно указывать несколько generic-параметров - их нужно указывать через ,
.
function foo<T, S>(a: T): S {
...
}
const bar = <T, S>(a: T): S {
...
}
Generic-параметры можно определять у типов объектов. Их нужно указывать в <>
сразу после названия типа объекта. Это правило работает как для интерфейсов, так и для type aliace-ов.
interface A<T, S> {
a: T;
b: S;
}
type B<T, S> = {
a: T;
b: S;
}
const a: A<number, string> = {
a: 10,
b: "lalaka"
}
const b: B<boolean, {a: number}> = {
a: true,
b: {
a: 10
}
}
Автовывод типов generic-параметров
TypeScript умеет автоматически выводить generic-параметры при помощи типов тех значений, которые были использованы.
function foo<T>(value: T): T[] {
return [value];
}
const q1: number[] = foo(1);
const q2: string[] = foo("lalaka");
const q3: string[] = foo(1); // Error: Type 'number[]' is not assignable to type 'string[]'.
Автовывод типа generic-параметров выполняется почти по тем же правилам, что и для вывода обычных типов. Если объявлен всего один generic-параметр, то в первую очередь TypeScript пытается использовать литеральный тип, если такое возможно.
const q4: 1[] = foo(1);
Всегда можно явно задать generic-параметры. В таком случае автоматически выведенные типы использоваться не будут.
const q5: 1[] = foo<number>(1); // Error: Type 'number[]' is not assignable to type '1[]'.
Если объявлено больше одного generic-параметра, то TypeScript не будет выводить литеральный тип строк и чисел для generic-параметров, у которых нет ограничений (он них позже).
Важно отметить, что, если был явно задан хотя бы один gerneric, то остальные generic-и тоже нужно явно задавать.
function bar<T1, T2, T3>(a: T1, b: T2, c: T3): [T1, T2, T3] {
return [a, b, c];
}
const q1 = bar(1, 2, 3);
const q2 = bar<string>("qweq", true, false); // Error: Expected 3 type arguments, but got 1.
Для generic-параметров можно указывать дефолтное значение. Если нет ни одного явно заданного generic-параметра, то все generic-и с дефолтным значением автоматически выводятся, как и generic-и без дефолтного значения. Однако если явно указан хотя бы один generic-параметр, то для gereric-ов с дефолтным значением будет применен дефолтный тип, если ему не задали явного значения.
function baz<T1, T2 = T1>(a: T1, b: T2): [T1, T2] {
return [a, b];
}
const q1: number = baz(1, 2)[1];
const q2: string = baz(1, "lalaka")[1];
/* T1 = string, тогда T2 = T1 = string. */
const q3: boolean = baz<string>("lalaka", true)[1]; // Error: Argument of type 'true' is not assignable to parameter of type 'string'.
const q4: boolean = baz<string, boolean>("lalaka", true)[1];
Для типизации объектов также можно использовать дефолтное значение типа.
interface A<T = string> {
a: T;
}
const q1: A = { a: "lalaka" };
const q2: A<boolean> = { a: true };
Для того, чтобы лучше понимать, как работают generic-и и зачем они нужны, давайте рассмотрим типизацию метода reduce
. В JavaScript этот метод можно использовать несколькими способами.
const array = [1, 2, 3];
const q1 = array.reduce((acc, cur) => acc + cur); // result: 6
const q2 = array.reduce((acc, cur, index) => index%2 === 0 ? acc + 1 : acc, 0); // result: 2
const q3 = array.reduce((acc, cur, index, array) => index > 0 ? acc + array[index - 1] : acc, ""); // result: "12"
В примере выше для q1
используется reduce
начальное значение которого не задано. В качестве начального значения используется первый элемент массива. Соответственно, в reduce
передается только callback и возвращается значение, тип которого такой же, как тип элементов массива.. Для переменной q2
начальное значение задано, но тип этого значения такой же, как и тип элементов массива. Соответственно, в reduce
передается 2 параметра, а тип возвращаемого значения совпадает с типом элементов массива. И для q3
все точно также, как и для q2
, но тип начального значения отличается от типа элементов массива. Поэтому тип возвращаемого значения не совпадает с типом элементов.
В TypeScript "из коробки" типизация этих кейсов разбита на 3 перегрузки. Первая перегрузка выглядит примерно так (примеры ниже не учитывают, что метод reduce
- это часть массива - для упрощения описывается лишь параметров и возвращаемого значения reduce
)
interface Reduce<T> {
(callback: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
}
В этом кейсе функция принимает callback
, почти все параметры которого имеют generic-тип. То есть с точки зрения типизации и реализации функции reduce
и callback
не важно какого типа будут элементы массива - реализация reduce
описывает общий код, который абстрагирован от типов элементов массива.
interface Reduce<T> {
(callback: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
(callback: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
}
Во второй перегрузке добавляется 2-ой параметр. Он также имеет generic-тип. Больший интерес представляет третья перегрузка.
interface Reduce<T> {
(callback: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
(callback: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
<R>(callback: (previousValue: R, currentValue: T, currentIndex: number, array: T[]) => R, initialValue: R): R;
}
Здесь вводится дополнительный generic-параметр R
, который можно задать конкретно для этой перегрузки или он будет автоматически выведен TypeScript. Более того, изменилась сигнатура callback
- теперь первый параметр и возвращаемое значения этой функции - это R
. Это все также абстрагированный тип, который может отличаться (а может не отличаться) от T
. Возвращаемое значение самой функции reduce
тоже стало R
. Посмотрим, как пример для JavaScript будет работать с описанным выше интерфейсом в TypeScript.
interface Reduce<T> {
(callback: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
(callback: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
<R>(callback: (previousValue: R, currentValue: T, currentIndex: number, array: T[]) => R, initialValue: R): R;
}
const reduce: Reduce<number> = [1, 2, 3].reduce;
const q1: number = reduce((acc, cur) => acc + cur); // result: 6
const q2: number = reduce((acc, cur, index) => index%2 === 0 ? acc + 1 : acc, 0); // result: 2
const q3: string = reduce((acc, cur, index, array) => index > 0 ? acc + array[index - 1] : acc, ""); // result: "12"
Ограничения generic-параметров
Во всех предыдущих примерах в качестве generic-ов можно было передавать любой тип. В TypeScript есть возможность ограничивать типы, которые могут использоваться в generic-е. Для того, чтобы задать ограничение (constraint) для типов generic-параметра, нужно воспользоваться ключевым словом extends
.
function foo<T extends string>(a: T) {
}
foo("qweqw");
foo(1); // Error: Argument of type '1' is not assignable to parameter of type 'string'.
Выражение T extends string
означает, что в качестве типа T
может использоваться любой тип, который совместим с типом string
. В качестве ограничения одного generic-параметра может быть использован другой generic-параметр.
function bar<T, R extends keyof T>(obj: T, keys: R[]) {
}
bar({a: 10, b: 15}, ["a", "b"])
bar({a: 10}, ["lalaka"]) // Error: Type 'string' is not assignable to type '"a"'.
При вызове функции bar
компилятор по первому параметру автоматически определяет тип второго параметра, если gereric-и не были заданы явно. В IDE, которые поддерживают проверку TypeScript, сразу можно увидеть подсказку о том, что TypeScript вывел тип R
из переданного T
.

Если у generic-параметра задано ограничение до string
или number
, то для таких generic-ов TypeScript будет выводить литеральный тип.
function foo<T, R>(a: T, b: R): [T, R] {
return [a, b];
}
const q1: "lalaka" = foo("lalaka", 10)[0]; //Error: Type 'string' is not assignable to type '"lalaka"'.
const q2: 10 = foo("lalaka", 10)[1]; //Error: Type 'number' is not assignable to type '10'.
function bar<T extends string, R extends number>(a: T, b: R): [T, R] {
return [a, b];
}
const q3: "lalaka" = bar("lalaka", 10)[0]
const q4: 10 = bar("lalaka", 10)[1]
Задания
Задание #6
Пример решения задания #6
Last updated
Was this helpful?