Использование дженериков в функциях

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

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

Допустим, я хочу типизировать функцию filter, которая принимает массив и функцию предикат. Сама функция filter выглядит так:

function filter(array, predicate) { const result = []; for(const element of array) { if (predicate(element)) { result.push(element); } } return result; }

Как мне ее типизировать? Я должен указать тип для аргументов array и predicate, а также тип возвращаемого значения (он же совпадает с типом переменной result). При этом нужно учесть, что массив может быть любым, а функция должна быть именно предикатом (то есть возвращать boolean, определяя подходит элемсент или нет).

Первый вариант решения

function filter( array: unknown[], predicate: (element: unknown) => boolean, ): unknown[] { const result: unknown[] = []; for(const element of array) { if (predicate(element)) { result.push(element); } } return result; }

В принципе уже неплохо. От всех этих некорректных вызовов тайпскрипт нас защитит:

filter(); // мало аргументов filter(["a", "b", "c"]); // мало аргументов filter(["a", "b", "c"], (x: string, b: string) => a + b); // неправильный второй аргумент filter(123, (x: number) => x > 0); // неправильный первый аргумент filter(["a", "b", "c"], (x: string) => x.length === 1, 123); // лишний третий аргумент

Проблемы решения с unknown

Но хочется, чтобы тайпскрипт проверял еще две вещи:

  • функция filter возвращает массив такого же типа, как массив array;
  • колбэк predicate принимает в качестве аргумента элементы масива.

Иначе получается, что-то странное:

const result = filter(["a", "b", "c"], (x: string) => x.length === 1);

Тип resultunknown[]. Спасибо, что массив, но хотелось бы, чтобы был массив строк. На колбэк тайпскрипт вообще ругается, потому что колбэк должен принимать вообще все что угодно, а я указал string.

Использование Generic Types

Тут нам и помогут дженерики:

function filter<T>( array: T[], predicate: (element: T) => boolean, ): T[] { const result: T[] = []; for(const element of array) { if (predicate(element)) { result.push(element); } } return result; }

Здесь T — это как переменная функции, но на уровне типов.

Технически я теперь могу вызывать функцию вот так:

const result1 = filter<string>(["a", "b", "c"], (x: string) => x.length === 1); const result2 = filter<string>(["a", "b", "c"], (x: number) => x > 0); const result3 = filter<string>([1, 2, 3], (x: string) => x.length === 1);

Теперь result1 имеет корректный тип string[], который тайпскрипт вывел сам (я не писал const result1: string[]), а в примерах result2 и result3 показывается ошибка про несовместимые аргументы.

Тайпскрипт выводит типы самостоятельно

На самом деле тайпскрипт и без нашей подсказки <string> сможет догадаться, что T в этом примере является строкой, если хотя бы по одному аргументу это будет понятно.

const result4 = filter(["a", "b", "c"], x => x.length === 1);

Первым аргументом мы передлаем массив ["a", "b", "c"], это очевидно массив строк string[]. При этом первым аргументом месте ожидается T[].

Если T[] === string[], то T === string. Значит, в x => x.length === 1 переменая x это string, так как там ожидалась функция вида (element: T) => boolean, а x это есть наш element. Сам result4 имеет тип T[], это тогда string[].

const result5 = filter([], (x: number) => x > 0);

Вторым аргументом ожидается функция (element: T) => boolean, а мы передаем (x: number) => x > 0. Путем нехитрого сравнения этих функций тайпскрипт вводит, что T — это number. Массив [], передаваемый первым аргументом вообще пустой. Но раз мы уже поняли, что T — это number, то и [] — это теперь пустой массив чисел. Аналогично и result5 имеет тип T[], а значит number[].

const result6 = filter(["a", "b", "c"], (x: number) => x > 0); const result7 = filter(["a", "b", "c"], x => x + x);

В примерах 6 и 7 есть несоотвествия.

Если ["a", "b", "c"] это string[], то T === string. Тогда почему (element: T) => boolean принимает number?

Во втором примере пробемы с T нет. Из первого аргумент выводим, что T === string. Но тогда колбэк (второй аргумент) возвращает сумму строк, то есть опять строку. А мы ожидаем boolean. Опять ошибка.

Таким образом дженерики позволяют:

  • проверить, что все передаваемые аргументы не противоречат друг другу;
  • вывести одни типы из других автоматически (например, возвращаемое значение из аргументов).

Когда нужно подсказать

Допустим, у меня есть функция split, разделяющая массив на две половинки. Она возвращает tuple (кортеж) из двух значений — массивов такого же типа, как исходный.

function split<T>(array: T[]): [T[], T[]] { const mid = Math.floor(array.length / 2); return [array.slice(0, mid), array.slice(mid)]; }

Тогда тайпскрипт сам сможет вывести тип, если он понимает, какого типа входной аргумент:

const result1 = split([1,2,3,4]); // [number[], number[]]

Но если передать пустой массив, то тайпскрипт сделает вывод, что подразумевался массив из элементов типа never. То есть массив, в котором не может быть никаких элементов (never — тип, в котором нет никаких значений).

const result2 = split([]); // [never[], never[]]

А я имел в виду, что это массив чисел. Просто в этом примере он оказался пустым. Тогда тайпскрипту все-таки нужно подсказать:

const result2 = split<number[]>([]); // [number[], number[]]

Например, мы так очень часто делаем в реакте, когда передаем пустую коллекцию в useState:

const [ids, setIds] = useState<string[]>([]); const [counter, setCounter] = useState<Record<string, number>>({});

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

Могло сложиться впечатление, что теперь функция filter может принимать массив только из чисел или массив только из строк. Иными слова, что массив обязан быть гомогенным.

Но вот такой вызов имеет смысла не меньше, чем фильтрация положительных чисел в массиве чисел:

filter([1, 2, "qwe", 5, "asd"], element => typeof element === "number");

В этом примере тайпскрипт выведет, что типом массива является union number | string, и подставит его в T. Соотвественно, аргумент колбэка element тоже автоматически станет number | string.

Несколько дженериков

Иногда бывает, что одного дженерика не достаточно.

Например, я хочу типизировать функцию map. Как она должна работать?

const result1 = map([2, 3, 4], num => num * 2);

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

function map(array: T[], cb: (x: T) => T): T[] { const result: T[] = []; for(const element of array) { result.push(cb(element)); } return result; }

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

const result2 = map(["qwe", "as", "z"], str => str.length); const result3 = map([1, 2, -5], num => num > 0);

В первом случае функция map принимает массив строк и функцию из строки в число, а возвращает, соответсвенно, массив чисел. Во втором — массив чисел првращается в массив булеанов. В этот момент и возникает второй дженерик. Записывается это так:

function map(array: T[], cb: (x: T) => S): S[] { const result: S[] = []; for(const element of array) { result.push(cb(element)); } return result; }

Ограничения через extends в дженериках

Пусть у меня есть какой-то модуль, который работает с какими-то документами. В нем есть функция, инкрементирующая версию документа и возвращающую новую версию.

interface Document { type: "pdf" | "html" | "txt"; name: string; version: number; } function bumpVersion(doc: Document): number { doc.version++; return doc.version; }

Следуя принципу Interface segregation (см. принципы SOLID), я бы отказался от конкретизации того, что мы работаем именно с Document. У нас есть уникальная возможность написать функцию, которая увеличивает версию у чего угодно.

Действительно, мы же в коде нигде не используем, что это Document. Тогда мы сможем не реализовывать похожую функцию, которая увеличивает номер версии у чего-то другого, тем самым не дубировать код (см. принцип DRY).

Для этого объявим вспомогательный интерфейс Versionable. Типа объект версионируемый или обладающий свойством версионируемости, т.е. содержит поле "version".

interface Versionable { version: number; } function bumpVersion(versionable: Versionable): number { versionable.version++; return versionable.version; } const doc: Document = { type: "pdf", name: "report.pdf", version: 5 }; bumpVersion(doc);

Пока все нормально. При чем тут дженерики?

Теперь я хочу, чтобы объект не мутировался, а наша функция возвращала новый объект с обновленной версией.

Так у нас теряется возможность работать с любыйми версионируемыми объектами:

function withBumpedVersion(doc: Document): Document { return { ...doc, version: doc.version + 1 }; } const doc: Document = { type: "pdf", name: "report.pdf", version: 5 }; const doc2 = withBumpedVersion(doc);

А так мы можем передавать не только Document, но назад получаем Versionable, а не то, что передали.

function withBumpedVersion(doc: Versionable): Versionable { return { ...doc, version: doc.version + 1 };. } const doc: Document = { type: "pdf", name: "report.pdf", version: 5 }; const doc2 = withBumpedVersion(doc); // Versionable doc2.name; // ошибка, такого свойства нет

На помощь приходят дженерики:

function withBumpedVersion<T extends Versionable>(doc: T): T { return { ...doc, version: doc.version + 1 };. } const doc: Document = { type: "pdf", name: "report.pdf", version: 5 }; const doc2 = withBumpedVersion(doc); // Document withBumpedVersion({ x: 1, y: 2 }); // ошибка, нет поля version withBumpedVersion({ x: 1, y: 2, version: 0 }); // все ок

Теперь функция withBumpedVersion возвращает в точности то, что принимает. Но при этом на аргумент накладывается ограничение: что угодно передать нельзя.

Дженерики зависят друг от друга

Допустим, я хочу реализовать функцию, которая фильтрует массив объектов по значению какого-то ключа.

function filterByKey(array, key, value) { return array.filter(obj => obj[key] === value) }

Во-первых, хорошо бы проверить, что array является массивом объектов. Во-вторых, что key является ключом таких объектов, а value соответствует значению по этому ключу.

interface Person { name: string; age: number; gender: "male" | "female"; } const arr: Person[] = [] filterByKey(arr, "gender", "male"); // должно работать filterByKey(arr, "gender", "[РОСКОМНАДЗОР]"); // ошибка, не может быть такого значения, есть два гендера filterByKey(arr, "citizeship", "UK"); // ошибка, нет такого ключа, Родина может быть только одна

Тип T отвечает за тип элемента массива, мы его ограничиваем типом object. Тогда мы не сможем передать в функию массив примитивов.

Тип K отвечает за ключ объекта. Мы его ограничиваем юнионом из всех ключей T, который получаем с помощьюб оператора keyof. В нашем примере keyof Person === "name" | "age" | "gender". Ну а дальше тогда значение по ключу value выводится через type indexing как T[K].

function filterByKey<T extends object, K extends keyof T>( array: T[], key: K, value: T[K] ): T[] { return array.filter(obj => obj[key] === value); }

Названия для дженериков

Во всех примерах, где был один дженерик, я использовал букву T для введения типа. Буква T — это сокращение от Type. И единственная причина, по которой мы ее используем, потому что так короче.

В примере, где появлялась необходимость во втором дженерике, я использовал S и K.

Если мы говорим про часто используемые буквы, то обычно T используется для основного типа (сокращение от Type), а S, U и V — для дополнительных.

Когда мы используем структуры формата ключ-значение, то разумно использовать K для ключа и V для значения. Для аккумулятора я бы использовал A, для какого-нибудь результата — R. И так далее.

В целом можно использовать и длинные названия, записанные в нотации PascalCase. Например, для декоратора, принимающего функцию с одним аргументом, можно использовать Arg и Result.

function memo(fn: (x: Arg) => Result): (x: Arg) => Result { // ... }

Задачи на TypeScript

Теория по TypeScript