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]

Задания

Last updated