Серия

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

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

Первый взгляд на обобщения

Обобщённый тип (обобщение, дженерик) позволяет резервировать место для типа, который будет заменён на конкретный, переданный пользователем, при вызове функции или метода, а также при работе с классами.

Допустим, у нас есть функция, принимающая на вход один аргумент и возвращающая его же без каких-либо изменений. Такая функция присутствует во многих библиотеках функционального программирования и носит имя identity. В голове вы можете держать аналогию с командой echo в Unix или print в Python.

Посмотрим на простейшую реализацию функции, описанной выше, если бы в TypeScript не было обобщённых типов и нам пришлось бы довольствоваться тем, что уже есть.

function identity(arg: any): any {
	return arg;
}

Всё как в описании: функция принимает аргумент любого типа и возвращает его же. Однако, это не совсем так. Внимательно присмотритесь к типам: в примере указано, что функция принимает аргумент какого-то типа и возвращает значение какого-то типа, но при этом они никак не связаны. Грубо говоря, сейчас мы можем передать аргумент типа number и получить значение типа string – это валидно, потому что any подразумевает под собой всё что угодно.

Сейчас функция представляет собой «чёрный ящик», в который с одной стороны что-то входит, а с другой стороны что-то выходит, возможно, похожее на то, что входило или нет – непонятно. Я бы сказал, что такую функцию можно называть «обезличивающей функцией» в контексте типов. При использовании any в качестве типа возвращаемого значения мы обезличиваем результат функции.

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

function identity<T>(arg: T): T {
	return arg;
}

Здесь T – это некоторый параметр-тип T, который будет захвачен при вызове функции. Конструкция <T> после имени функции указывает на то, что эта функция собирается захватить тип и подменить им все T.

Можно использовать любые буквы, которые вам хочется, но буквы на входе и выходе функции должны совпадать, если этого требует логика её работы. Так уж сложилось, что, если имеется единственный параметр-тип, то он получает имя T, но лишь в том случае, если это не нарушает общую ясность. При объявлении нескольких параметр-типов их имена записываются, чаще всего, как T, U и A, соответственно. Однако, существует и другое соглашение, поощряющее именование всех параметр-типов через T, но с применением уточнений, например, TKey, TKeyType или TValue. Официальная документация и разработчики языка придерживаются первого соглашения.

В мире C#, технически, можно утверждать, что identity<T> – это открытый тип, а identity<number> – замкнутый. При этом нужно понимать, что вы можете работать только с замкнутыми типами.

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

const value = identity<number>(115);

При вызове функции тип <number> заполняет обобщённый параметр T. В этот момент компилятор неявно подставляет вместо T переданный тип number и переходит к валидации типов переданных аргументов, а затем входит внутрь функции и валидирует её тело.

Примечание

Самое часто используемое в TypeScript обобщение – Promise<T>. Например, это обещание, возвращающее строку function a(): Promise<string> { return 'a' }.

Логический вывод обобщённых типов

Многих разработчиков смущает указание типа с помощью знаков «меньше» и «больше». Для упрощения создания, чтения и работы с кодом TypeScript имеет возможность логического вывода типов при вызове обобщённых типов. Это значит, что компилятор будет пытаться определить (логически вывести) тип, который автоматически будет использоваться при вызове обобщённого типа.

const value = identity('string');

В отличие от C#, в TypeScript нет возможности написать две функции с одним именем, когда первая функция реализована для конкретного типа, а вторая – для обобщённого. Например, такое в TypeScript написать не получится:

function log(arg: string): void {
	console.log(arg);
}

function log<T>(arg: T): void {
	console.log(arg);
}

// Error → Duplicate function implementation

Также стоит упомянуть, что компилятор выдаст ошибку, если он не сможет точно вывести тип для параметр-типа. Для того, чтобы разрешить сложившуюся ситуацию нужно явно указать типы.

function swap<T>(a: T, b: T) {
	const temp = a;
	a = b;
	b = temp;
}

const a: string = 'string';
const b: number = 123;

swap(a, b);
// Error → The type argument for type parameter 'T' cannot be inferred from the usage. Consider specifying the type arguments explicitly.

Для чего существуют обобщённые типы

При написании кода вы всегда встаёте перед выбором, имеющим как минимум две ветви решения. Первый путь приводит вас к копированию существующего кода с нужным вам типом, что также приводит к «распуханию» кода и необходимости думать при рефакторинге. Второй путь – унификация, что подразумевает под собой параметрический полиморфизм, в основе которого лежит использование одного и того же кода, но с разными типами. Получается, что обобщённые типы нужны для написания кода, который можно многократно использовать вне зависимости от типов.

Безопасность типов

Когда обобщённый код (например, алгоритм) применяется с конкретным типом, компилятор гарантирует нам, что в алгоритме будут использоваться лишь те «объекты», что совместимы с этим типом данных. В случае, если попытаться передать не совместимый с кодом тип, то компилятор породит ошибку.

Более простой и понятный код

Поскольку компилятор защищает нас от передачи аргументов неправильных типов, это позволяет использовать меньше защитников типов, которые были рассмотрены в предыдущей статье. Однако, работает только в том случае, если ваш стек полностью построен на TypeScript и это не будет работать в том случае, если вы распространяете свой код в скомпилированном виде. В таком случае вы обязаны использовать защитники типов, если хотите оберегать своих пользователей от неправильного использования вашего кода.

Базовые ограничения обобщённых типов

Посмотрим на код ниже и попытаемся ответить на вопрос: что здесь может пойти не так?

function getLength<T>(arg: T): number {
	return arg.length;
}

Ответ достаточно прост: компилятор ничего не знает про тип аргумента. По умолчанию, вместо обобщённого параметра можно подставить любой тип, поэтому компилятор не знает, что это за тип такой, – T и есть ли у него свойство length. Такая проблема решается на уровне разработчика с помощью ограничений. В нашем случае мы хотим ограничить принимаемое множество типов T условием: наличием свойства length. Для этого нужно создать некоторый интерфейс, где указано нужное свойство и расширить его, используя обобщённый тип T.

interface Lengthwise {
	length: number;
}

function getLength<T extends Lengthwise>(arg: T): number {
	return arg.length;
}

В коде выше мы оповещаем компилятор о том, что на вход функции getLength могут подаваться аргументы лишь того типа, что имеют свойство length. Такая запись защитит вас от передачи в функции, например, аргумента типа number.

Примечание

Ограничения позволяют сузить перечень типов, которые можно будет передать в обобщённом аргументе, и расширяет возможности по работе с этими типами.

А ещё пример выше может быть записан с использованием обощения Array<T>, которое вскользь упоминалось во второй статье серии. В этом случае arg должен иметь все свойства и методы класса Array, включая необходимое в этом примере свойство .length:

function getLength<T>(arg: Array<T>): number {
	return arg.length;
}

Если же в вашей функции требуется только свойство .length, то вы можете использовать встроенный обобщённый интерфейс ArrayLike:

function getLength<T>(arg: ArrayLike<T>): number {
	return arg.length;
}

Можно ограничивать не только принимаемые аргументы по типу, но и сами параметр-типы. В большинстве руководств приводится пример копирования свойств объекта или получения значения свойства из объекта. Я предлагаю не отклоняться от них и рассмотреть работу функции обновления значения свойств объекта из другого объекта. Важным условием здесь является то, что мы можем лишь обновлять значения свойств, а не добавлять новые – да, тип пересечения (Object.assign, который рассматривался в предыдущей статье серии) здесь не подойдёт.

function updateProperties<T, U extends T>(target: T, source: U): T {
	for (let key in target) {
		target[key] = source[key];
	}

	return target;
}

Приведённая выше функция принимает на вход два типа: T и U, причём тип U расширяет тип T. На практике это означает, что в качестве аргумента source вы можете передать лишь тот объект, что включает в себя не только свои свойства, но и все обязательные свойства описанные типом T. Рассмотрим это утверждение на примере.

Пусть у нас есть три объекта, каждый из которых представлен своим интерфейсом, соответственно:

const a: IObjA = { a: 1 };
const b: IObjB = { b: 2 };
const c: IObjC = { a: 3, b: 3 };

Теперь попробуем передать в нашу функцию объект из переменной a и b:

updateProperties<IObjA, IObjB>(a, b);
// Error → Type "IObjB" does not satisfy the constraint "IObjA". Property "a" is missing in type "IObjB".

Компилятор выдаст ошибку, потому что интерфейс IObjB не имеет свойства a, которое требуется интерфейсом IObjA. Такое поведение оправданно наличием ограничения. Однако, при передаче объекта, имплементирующего интерфейс IObjC, ошибки не будет, потому что интерфейс IObjC предполагает наличие обязательного свойства a.

Уточнения с помощью обобщений

Рассмотрим ситуацию, в которой у нас имеется функция, возвращающая значение свойства из переданного объекта по его имени. Даже в том случае, если мы напишем интерфейс и имплементируем его, компилятор не сможет понять: принадлежит ли переданное имя свойства переданному объекту или нет. И в этот момент на помощь приходит ещё одно ключевое слово keyof, предоставляющее доступ ко всем именам свойств в структурном типе данных.

Все мы знаем, что типов и поколений покемонов так много, что без TypeScript-а и структур данных здесь разобраться не получится. Давайте напишем простейший интерфейс, описывающий покемонов: тип, вес, высота и сила атаки.

enum PokemonType {
	Fire,
	Water,
	Flying
}

interface IPokemon {
	type: PokemonType;
	weight: number;
	height: number;
	attack: number;
}

Хорошо, а теперь посмотрим на работу ключевого слова keyof – создадим некоторый тип K1, включающий в себя имена всех свойств интерфейса IPokemon:

type K1 = keyof IPokemon; // type | weight | height | attack

А теперь напишем функцию, которая умеет работать не только с интерфейсом покемонов, но и с другими интерфейсами, используя обобщения.

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
	return obj[key];
}

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

const charmander: IPokemon = {
	type: PokemonType.Fire,
	weight: 8.5,
	height: 0.6,
	attack: 52
};

const type = getProperty(charmander, 'type');
// Type: PokemonType
const health = getProperty(charmander, 'health');
// Error → Argument of type "health" is not assignable to parameter of type "type" | "weight" | "height" | "attack".

В первом случае мы видим, что переменная type не только получила значение, хранящееся в свойстве, но и его тип – PokemonType. Во втором случае мы получили ошибку компилятора, потому что объект charmander не имеет свойства с именем health.

Замечательно, а что нам это даёт? Зачем все эти сложные конструкции с keyof, когда можно обратиться к свойству объекта через точку или квадратные скобки? – всё не так просто, как может показаться на первый взгляд.

Врядли кто-то из нас будет писать функцию, которая просто возвращает значение свойств без каких-либо ещё действий и логики. Конструкция keyof – это гарант того, что вы пытаетесь передать в функцию только разрешённый объект и существующее имя свойства, при этом взамен вы получаете не только значение свойства, но и его тип.

Для примера упростим функцию getProperty до примитивного состояния и попытаемся получить значение свойства type, просто передав его в качестве аргумента key:

function getProperty<T>(obj: T, key: string) {
	return obj[key];
}

const type = getProperty<IPokemon>(charmander, 'type');
// Type: any
const hp = getProperty<IPokemon>(charmander, 'health');
// Type: any

Ошибок нет. Типов нет. Уверенности в том, что функция не просто работает, а работает правильно – нет. Именно для этого нужна статическая типизация – для того, чтобы быть уверенным в том, что ваша функция работает так, как вы и задумывали. Здесь лишь одна оговорочка: речь идёт только про возвращаемый тип, а не про поведение функции.

Обобщения и интерфейсы

Обобщения не обошли стороной и интерфейсы.

interface User<T> {
	identificator: T;
}

type UserWithNumberIdentificator = User<Number>;
type UserWithStringIdentificator = User<String>;

Но это скучно и понятно – давайте попробуем посмотреть на более сложный пример. Допустим, имеется интефейс, описывающий пользователя какого-нибудь простенького сервиса:

interface IPerson {
	name: string;
	age: number;
	money: number;
}

Вы используете этот интерфейс в своих функциях и в какой-то момент вам понадобился точно такой же интерфейс, но свойства которого являются необязательными. И вот снова выбор: копировать и расставлять ? (необязательные параметры) или написать код, который может быть использован не один раз, то есть переиспользоваться. Конечно же вы выбрали второй путь, иначе самое время закрыть эту вкладку браузера.

Достичь универсальности поможет сопоставление и обобщения. В примере ниже я описал тип Partial, который возвращает новый тип, но делает обязательные свойства – необязательными. В этом типе используется индексируемый тип, раскрываемый для каждого имени свойства передаваемого объекта.

type Partial<T> = {
	[K in keyof T]?: T[K];
};

type PartialPerson = Partial<Person>;
// Type: {
//   name?: string;
//   age?: number;
//   money?: number;
// }

Однако не спешите писать такой тип в ваших проектах сами: тип Partial уже существует в TypeScript и не требует пользовательского определения.

Некоторые встроенные типы для обобщений

В функциональном программировании, да и в программировании вообще, часто встречается необходимость изъять из какого-либо объекта не значение одного свойства, а двух или нескольких. Например, это может быть «фильтр» данных. Напишем такой «фильтр», используя уже знакомые нам типы и структуры, в рамках всё тех же покемонов.

function pick<T, K extends keyof T>(obj: T, ...keys: K[]) {
	const set: any = {};

	for (const key in obj) {
		if ((<string[]>keys).indexOf(key) !== -1) {
			set[key] = obj[key];
		}
	}

	return set;
}

const a = pick(charmander, ['type', 'weight']);
// Type: any

Да, это не вызывает ошибки компиляции и мы до сих пор можем вписать в массив лишь имена существующих свойств, но компилятор думает, что функция возвращает любой тип и ничего не знает о нём. Именно для этого в TypeScript есть встроенный тип Pick.

function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K> {
	const result = {} as Pick<T, K>;

	for (const key in obj) {
		if ((<string[]>keys).indexOf(key) !== -1) {
			result[key] = obj[key];
		}
	}

	return result;
}

const a = pick(charmander, 'type', 'weight');
// Type: Pick<IPokemon, "type" | "weight">

Примечание

Код немного грязный, но я не смог придумать более элегантного решения с типами.

Основные типы для обобщений:

  • Partial<T> – указывает, что все свойства некоторого типа T являются необязательными
  • Readonly<T> – указывает, что все свойства некоторого типа T доступны только для чтения
  • Record<K extends string, T> – конструирует объект, у которого значения свойств имеют некоторый тип T
  • Pick<T, K extends keyof T> – выделяет из некоторого типа T некоторый набор свойств K

А вот пример с pluck, тип которого не предоставляется TypeScript из коробки. Эта функция принимает только объекты, у которых есть общие свойства и возвращает массив с их значениями. Если вы укажете b в качестве ключа, то компилятор выдаст ошибку.

function pluck<T, K extends keyof T>(objs: T[], key: K): T[K][] {
	return objs.map(obj => obj[key]);
}

const a = pluck([{ a: 1 }, { a: 'c', b: 2 }], 'a');
// Type: (number|string)[]

TypeScript имеет большое количество стандартных типов, которые могут использоваться в вашем коде с обобщениями. Полный список не описывается в документации, но вы можете почитать описание ES5. Также существует модуль typescript-collections, имплементирующий многие «стандартные» структуры данных на TypeScript.

Обобщения в классах

Для примера напишем класс очереди сразу применяя все знания полученные в этой статье. Пусть класс Queue принимает некоторый обобщённый тип T, а также имплементирует методы push и pop.

class Queue<T> {
	private data = [];

	public push = (item: T) => this.data.push(item);
	public pop = (): T => this.data.shift();
}

const queue = new Queue<number>();

Здесь особо не о чем говорить, потому что всё то, что можно сделать с обобщениями в интерфейсах – можно сделать и в классах. Просто имейте в виду. Зато здесь можно поговорить об ограничениях.

В параметр-типе можно задавать не более одного ограничения конструктора – ограничение задаётся с помощью ключевого слова new, гарантирующего, что переданный аргумент вообще может быть инициализирован. Для примера напишем функцию createInstance, которая принимает какой-либо класс в качестве аргумента и возвращает экземпляр этого класса:

function createInstance<T>(c: { new(): T; }): T {
	return new c();
}

class Animal {
	numLegs: number;
}

const animal = createInstance(Animal); // Animal

При необходимости можно ограничить принимаемое множество типов, если указать, что некоторый тип A должен быть потомком класса Animal, используя уже известный синтаксис – расширитель extends:

function createInstance<A extends Animal>(c: new() => A): A {
	return new c();
}

class Animal {
	numLegs: number;
}

class Lion extends Animal {}

const lion = createInstance(Lion);

Если же передать класс, который наследуется не от класса Animal, то компилятор выдаст ошибку, говорящую о том, что мы пытаемся его обмануть и подсунуть неверный тип:

class Panda {
	awesome: boolean;
}

const panda = createInstance(Panda);
// Error → Argument of type 'typeof Panda' is not assignable to parameter of type 'new () => Animal'.

Ссылочки

По традиции оставляю пару ссылочек, которые помогут понять материал не только с моей стороны, но и со стороны других авторов. Не удивляйтесь, что в ссылках присутствуют материалы по C#, так как TypeScript берёт многие возможности именно из него – напомню, что у них один автор.