Дженерики в TypeScript

Привет, я Сергей Вахрамов, занимаюсь фронтенд-разработкой на Angular в компании Тинькофф. Во фронтенд-разработку вошел напрямую с тайпскрипта, просто перечитав всю документацию. С того момента и спецификация ECMAScript расширилась, и TypeScript сильно подрос. Казалось бы, почему разработчики могут бояться дженериков, ведь бояться там нечего? Мой опыт общения с джуниор-разработчиками говорит, что во многом ребята не используют обобщенные типы просто потому, что кто-то пустил легенду об их сложности.

Эта статья для тех, кто не использует generic-типы в TypeScript: не знают о них, боятся использовать или используют вместо реальных типов — any.

image-loader.svg

Дженерики, или Generic Types,  — обобщенные типы. Они нужны для описания похожих, но отличающихся какими-то характеристиками типов. Мы описываем общую структуру, а конкретную уже определяет пользователь дженерика. Дженерик — это каркас, внутренности которого заполняет разработчик. Программист, который описывает обобщенный тип, никогда не знает, что именно туда решит записать тот, кто будет этот тип использовать.

Посмотрим на пример использования дженериков в TypeScript. Представьте, что у нас есть массив значков валют. В JavaScript мы бы просто написали:

const currencySigns = ['₽', '€', '£'];

В TypeScript с помощью дженериков можно написать:

type CurrencySign = '₽' | '€' | '£';

const currencySigns: ReadonlyArray = ['₽', '€', '£'];

Здесь важно уделить внимание типу переменной currencySigns — ReadonlyArray, обобщенный тип, означает «неизменяемый массив», при этом мы говорим языку, что в нем могут лежать только элементы типа CurrencySign, это параметр дженерика. 

Ничто не запрещает написать ReadonlyArray<['₽', '€', '£']>, но часто типы разделяют, чтобы в будущем их было удобно использовать отдельно друг от друга. Например, как в данном случае, было бы удобно заранее иметь тип элемента массива и уже из него сконструировать другой тип, двигаясь «от меньшего к большему», а не выделять из большего типа меньший. Это возможно с помощью декларации infer, но об этом поговорим в другой раз.

Оператор keyof

Это оператор, который берет все ключи объекта и представляет в виде числового или строкового литерального объединения.

Давайте представим, что нам с сервера шлют объект с такой структурой:

type Payment = {
    amount: number;
    currency: string;
    currencySign?: string;
}

Если нам потребуются ключи из типа Payment, тут и пригодится оператор keyof.

type ObjectKey = keyof Obj;

В итоге получим:

type PaymentKeys = ObjectKey; //  'amount' | 'currency' | 'currencySign'

ObjectKey — это дженерик-тип (обобщенный тип), который перечисляет ключи объекта Obj, переданного в него как в параметр.

const key: PaymentKeys = 'amount'; // OK

const key: PaymentKeys = 'from'; // Ошибка, такого ключа у Payment нет

Что дальше?

Мы дали основные знания, которые помогут уверенно использовать мощь обобщенных типов. Теперь можно переходить к Generic Types.

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

Представим, что нам с сервера приходит описание платежа из истории в формате:

type PaymentInfo = {
    id: string;
    amount: number;
    currency: string;
}

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

type NewPaymentInfo = {
    id: string;
    amount: number;
    currency: number; // код валюты
}

Теперь нам приходит код валюты, а не ее буквенное обозначение. При этом на старые записи в истории мы все еще получаем строковый код. Чтобы не описывать разные типы и не создавать путаницу, можно объединить их в один обобщенный тип — дженерик:

type PaymentInfo = { // T — параметр дженерика
    id: string;
    amount: number;
    currency: T; // «настраиваем» тип поля currency
}

const paymentInfo: PaymentInfo = // …

Можно указать типы параметров дженерика по умолчанию. Если не передать в такой дженерик параметр, то TypeScript возьмёт значение по умолчанию:

type PaymentInfo = { … } // T — по умолчанию тип string

const paymentInfo: PaymentInfo = // … тип переменной — PaymentInfo

Возможно, пример не самый удачный, но он сделан для ввода в курс дела, дальше ждут более приближенные к реальной жизни примеры.

Почему T? Так сложилось, что параметры дженериков именуют одной буквой (T означает Type), но вы без проблем можете написать не T, а Currency:

type PaymentInfo = { … }

При этом, если в IDE мы попытаемся в paymentInfo присвоить полю currency значение типа number, получим ошибку: TypeScript уже охраняет нас. Этого бы не было, если бы тип поля currency был просто string | number. Ведь мы дали возможность разработчику с помощью параметра указать, значение какого типа будет лежать в поле currency.

Типизация функций и методов

Некоторые из функций могут быть вызваны с разным количеством аргументов и их типами. В TypeScript такие функции можно описать с помощью перегрузок.

Допустим, у нас есть функция identity — она возвращает аргумент, который мы ей передали.

function identity(arg: string): string;
function identity(arg: number): number;
function identity(arg: unknown[]): unknown[] {
    return arg;
}

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

function identity(arg: boolean): boolean;

Этот вариант страдает от возможности адекватной расширяемости: если понадобится такая функция identity, которая будет работать с Payments[], нам потребуется добавить еще одну сигнатуру. А если таких типов десяток или сотня? Писать 100 сигнатур — не выход из ситуации.

Но можно написать вот так:  

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

В этом примере мы просто типизировали функцию (Function Declaration) через дженерик. Можно также типизировать функциональное выражение (Function Expression):

const identity = (arg: T): T {
    return arg;
}

Типизация классов

Зачем может понадобиться обобщать классы? Уже на этапе разработки мы часто знаем, что один и тот же класс можно использовать для обслуживания разных данных. Например, выпадающему списку можно дать массив строк для отображения простого контента, а можно передать массив шаблонов для отрисовки сложного. Дженерики помогут описать такой класс.

Для упрощения представлю класс IdentityClass.

Код на JavaScript:

class IdentityClass {
    constructor(value) {
        this.value = value;
    }
  
    getIdentity() {
        return this.value;
    }
}

Тот же самый класс будет выглядеть намного понятнее с TypeScript. Для начала опишем интерфейс:

interface IdentityGetter {
    getIdentity(): Type;
}

Теперь напишем класс, который реализует наш интерфейс:

class IdentityClass implements IdentityGetter {
    constructor(private readonly value: T) {
        this.value = value;
    }

    getIdentity(): T {
        return this.value;
    }
}

Ограничения дженериков. Generic Constraints

Иногда нужно как-то ограничить тип, принимаемый дженериком. Покажу на реальном примере.

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

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

Если вы попробуете ее скомпилировать, получите ошибку:

Property 'length' does not exist on type 'T'.

Происходит это потому, что TypeScript не знает, есть ли у передаваемого аргумента свойство length. Это легко исправить с помощью Generic Constraint — ограничения дженерика. Создадим тип и укажем функции, что при типизации она может принимать только такой тип, который имеет свойство length типа number:

interface Lengthwise {
    length: number;
}

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

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

getLength(['Я', 'люблю', 'Тинькофф']) === 3
getLength('Я люблю Тинькофф') === 16
getLength(1027739642281) // Ошибка, у number нет свойства length

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

function getPropertyValue(obj: Obj, key: Key): Obj[Key] {
    return obj[key];
}

В этом примере также показана возможность ограничения типа, используемого в объявлении функции, с помощью уже имеющегося параметра:

function getPropertyValue(...) { … } // тип Key ограничен типом keyof Obj 

Теперь проверим:

const developer = {
    name: 'Sergey Vakhramov',
    nickname: 'vakhramoff',
    website: 'vakhramoff.ru',
}

getPropertyValue(developer, 'nickname') === 'vakhramoff'
getPropertyValue(developer, 'age') // Ошибка, у объекта в переменной developer нет свойства age

Охранники типов: Type Guards

В основном русскоговорящее сообщество не заморачивается и просто называет их тайп-гардами. Прежде всего сюда можно отнести операторы typeof и instanceof. Они помогают определять и различать типы.

Оператор typeof (typeof type guards). TypeScript — классный инструмент. Он позволяет выводить типы из конкретных переменных. Это называется Type Inference или «вывод типов».

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

const account = {
    amount: 1_000_000,
    currency: 'RUB',
    currencySign: '₽',
    locked: false,
};

Если мы захотим вывести тип этой переменной в коде, то без проблем сделаем это при помощи typeof:

type Account = typeof account;

// TypeScript сам выведет следующий тип:

// Account = {
//     amount: number;
//     currency: string;
//     currencySign: string;
//     locked: boolean;
// }

В JavaScript тоже есть оператор typeof, который позволяет узнать тип значения, который хранится в переменной.

typeof 'Hello, world!' === 'string';
typeof 1_234_567 === 'number';
typeof { nickname: 'vakhramoff' } === 'object';
typeof ['₽', '€', '£'] === 'object'; // подумайте, почему так
typeof null === 'object'; // засада!

Почему typeof null === «object»? Это общепризнанное поведение JS. Многие считают его багом, попытаюсь объяснить почему. 

Любая переменная хранит свое значение в виде последовательности битов. Из 32 бит, отведенных для хранения значения переменной, решили 3 выделить под хранение ее типа.

Мы понимаем, что всего возможно 2^3 = 8 вариантов типов и, по счастливой случайности, 000 выделили для типа object. Если вы когда-то встречались с понятием «нулевой указатель», то знаете, что он представляет собой переменную — последовательность нулей. Догадываетесь? Проверяя у этой переменной тип, оператор typeof в JavaScript встречает три нулика и понимает, что перед нами объект.

Оператор instanceof (instanceof type guards). Позволяет проверить, является ли данный объект экземпляром конкретного класса.

Тут ничего сложного. Для упрощения напишем классы без реализации и создадим экземпляры этих классов.

class Account { }
class PremiumAccount extends Account {}
class Currency { }

const account = new Account();
const premiumAccount = new PremiumAccount();
const currency = new Currency();

account instanceof Account === true
account instanceof PremiumAccount === false
premiumAccount instanceof Account === true
premiumAccount instanceof PremiumAccount === true
currency instanceof Account === false

User-Defined Type Guards

В TypeScript есть еще один прекрасный инструмент — «определенные пользователем тайп-гарды».

Допустим, у нас есть следующие интерфейсы:

interface Pet {
    name: string;
}

interface Cat extends Pet {
    meow(): void;
}

interface Dog extends Pet {
    bark(): void;
}

Нам нужно написать функцию, которая будет определять, является ли переданное животное объектом, реализующим интерфейс Dog. Если мы напишем в классическом стиле, то это вызовет ряд проблем — TypeScript не будет понимать, что перед ним точно объект, соответствующий интерфейсу Dog:

function isDog(pet: Pet): boolean {
    return (pet as Dog).bark !== undefined && typeof (pet as Dog).bark === 'function';
}

const pet: Pet = {
    name: 'Wolfgang',
    bark: () => console.log('Гав-гав!'),
}

if (isDog(pet)) {
    pet.bark(); // Ошибка! TypeScript не понимает, что pet — это Dog
}

Мы можем легко это исправить. Достаточно лишь сказать TypeScript, что наша функция определяет, реализует ли переданный аргумент интерфейс Dog:

function isDog(pet: Pet): pet is Dog {
    return ('bark' in pet) && typeof (pet as Dog).bark === ‘function’;
}

Теперь TypeScript не ругается, он понял тип переменной pet:

if (isDog(pet)) {
    pet.bark(); // OK, pet это Dog
}

Так же тайп-гард будет работать при условном ветвлении во время проверки наличия поля с помощью оператора in, если тип является объединением, и при этом условное выражение однозначно подразделяется на ветви true и false, это позволяет тайпскрипту однозначно сузить тип внутри этих условных ветвей:

function makeNoise(pet: Cat | Dog): void {
    if ('meow' in pet) {
        return pet.meow(); // тип pet «сужается» до Cat
    }

    return pet.bark(); // тип pet «сужается» до Dog
}

Сначала это может сложно восприниматься, поэтому подробнее про сужение типов предлагаю прочитать отдельно в документации.

Если искать примеры в реальном мире, то до определенной версии в jQuery невозможно было использовать метод isUndefined, который возвращал значение boolean вместо тайп-гарда ... is undefined. Хотя этот метод в тайпскрипте и был, но разработчики jQuery не описали возвращаемое функцией значение должным образом. Это могло сильно мешать при разработке.

Также хочу отметить, что при использовании Type Guard вся ответственность за определение типов лежит на разработчике. Он напрямую говорит TypeScript: «Это точно вот этот тип и никакой другой, я гарантирую».

В заключение

Мы узнали про дженерики и их параметризацию, научились с помощью них типизировать переменные, функции и методы, а также классы. Узнали, как можно ограничить типы и при необходимости помочь TypeScript с выведением типов, использовав Type Guard.

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

Для продвинутых пользователей языка рекомендую официальную книгу — TypeScript Handbook. Ее можно использовать как шпаргалку с паттернами, объяснением поведения системы вывода типов, описанием работы компилятора. Там раскрываются тонкие моменты, о которых разработчик не задумывается повседневно.

Если вы «познали дзен» в написании дженериков или просто хотите попрактиковаться на реальных примерах и набить руку, можете также порешать задачки Type Challenges в одноименном github-репозитории. В папке questions задачи разделены по сложности и пронумерованы. Под каждой есть ссылка на предлагаемые разработчиками решения — можете легко проверить себя.

© Habrahabr.ru