Где можно использовать generics
Перейти к содержимому

Где можно использовать generics

  • автор:

Дженерики в TypeScript: разбираемся вместе

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

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

Дженерики в TypeScript

В документации TypeScript приводится следующее определение: «дженерики — это возможность создавать компоненты, работающие не только с одним, а с несколькими типами данных».

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

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

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

identity.js

function identity (value) < return value; >console.log(identity(1)) // 1

Сделаем так, чтобы она работала с числами:

identity.ts

function identity (value: Number) : Number < return value; >console.log(identity(1)) // 1

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

genericIdentity.ts

function identity (value: T) : T < return value; >console.log(identity(1)) // 1

Ох уж этот странный синтаксис ! Отставить панику. Мы всего лишь передаём тип, который хотим использовать для конкретного вызова функции.

Посмотрите на картинку выше. Когда вы вызываете identity(1) , тип Number — это такой же аргумент, как и 1. Он подставляется везде вместо T . Функция может принимать несколько типов аналогично тому, как она принимает несколько аргументов.

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

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

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

Обратите особое внимание на второй вызов console.log на анимации выше — в него не передаётся тип. В этом случае TypeScript попытается вычислить тип по переданным данным.

Обобщённые классы и интерфейсы

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

Посмотрите на пример и попробуйте разобраться сами. Надеюсь, у вас получилось.

genericClass.ts

interface GenericInterface  < value: U getIdentity: () =>U > class IdentityClass implements GenericInterface  < value: T constructor(value: T) < this.value = value >getIdentity () : T < return this.value >> const myNumberClass = new IdentityClass(1) console.log(myNumberClass.getIdentity()) // 1 const myStringClass = new IdentityClass("Hello!") console.log(myStringClass.getIdentity()) // Hello!

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

  1. Создаётся новый экземпляр класса IdentityClass , и в него передаются тип Number и значение 1 .
  2. В классе значению T присваивается тип Number .
  3. IdentityClass реализует GenericInterface , и нам известно, что T — это Number , а такая запись эквивалентна записи GenericInterface .
  4. В GenericInterface дженерик U становится Number . В данном примере я намеренно использовал разные имена переменных, чтобы показать, что значение типа переходит вверх по цепочке, а имя переменной не имеет никакого значения.

Реальные случаи использования: выходим за рамки примитивных типов

Во всех приведённых выше вставках кода были использованы примитивные типы вроде Number и string . Для примеров самое то, но на практике вы вряд ли станете использовать дженерики для примитивных типов. Дженерики будут по-настоящему полезны при работе с произвольными типами или классами, формирующими дерево наследования.

Рассмотрим классический пример наследования. Допустим, у нас есть класс Car , являющийся основой классов Truck и Vespa . Пропишем служебную функцию washCar , принимающую обобщённый экземпляр Car и возвращающую его же.

class Car < label: string = 'Generic Car' numWheels: Number = 4 horn() < return "beep beep!" >> class Truck extends Car < label = 'Truck' numWheels = 18 >class Vespa extends Car < label = 'Vespa' numWheels = 2 >function washCar (car: T) : T < console.log(`Received a $in the car wash.`) console.log(`Cleaning all $ tires.`) console.log('Beeping horn -', car.horn()) console.log('Returning your car now') return car > const myVespa = new Vespa() washCar(myVespa) const myTruck = new Truck() washCar(myTruck)

Сообщая функции washCar , что T extends Car , мы обозначаем, какие функции и свойства можем использовать внутри этой функции. Дженерик также позволяет возвращать данные указанного типа вместо обычного Car .

Результатом выполнения данного кода будет:

Received a Vespa in the car wash. Cleaning all 2 tires. Beeping horn - beep beep! Returning your car now Received a Truck in the car wash. Cleaning all 18 tires. Beeping horn - beep beep! Returning your car now

Подведем итоги

Надеюсь, я помог вам разобраться с дженериками. Запомните, всё, что вам нужно сделать, — это всего лишь передать значение type в функцию 🙂

Если хотите ещё почитать про дженерики, я прикрепил далее пару ссылок.

Что почитать:

  • TypeScript Generics Documentation — документация дженериков
  • TypeScript Generics Explained — более глубокое погружение в тему дженериков

Для чего использовать дженерики в TypeScript

Дженерики (generic) помогают писать универсальный, переиспользуемый код, а также в некоторых случаях позволяют отказаться от any . Главная задача дженериков — помочь разработчику писать код, который одинаково будет работать со значениями разных типов.

Посмотрим на примере из реального мира.

Представьте завод по изготовлению автомобилей. Старый завод, который проектировался для сборки автомобиля определённой модели. На нём могут собирать только такую модель автомобиля, а если потребуется выпустить машину с немного другим кузовом, то придётся строить новый завод. Это неоптимальное решение. Если разные машины собираются одинаково, то лучше научиться собирать разные машины на одном заводе.

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

Суть дженериков

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

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

function includeStr(array: string[], query: string): boolean < // на входе массив и строка для поиска for (const value of array) < // перебираем массив if (value === query) < // если в массиве есть элемент — возвращаем true return true; >> // если ничего не нашлось, возвращаем false return false; > 

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

function includeNumber(array: number[], query: number): boolean < // всё то же самое, только на входе числа for (const value of array) < if (value === query) < return true; >> return false; > 

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

❌ Пишем много функций для разных типов

✅ Объявляем в функции параметр типа, а потом передаём через него нужный тип

Вместо конкретного типа, мы как будто объявляем «переменную», а затем передаём в неё нужный тип. Таким образом, получается код, который может работать с разными типами:

function include < T >(array: T[], query: T): boolean < for (const value of array) < if (value === query) < return true; >> return false; > 

Код функции не поменялся, но теперь мы не указываем конкретный тип. Мы заводим переменную T и говорим, что тип параметра array — это тип, который будет передан в переменную T . А тип параметра query — это тип, который будет передан через переменную T .

Когда мы захотим воспользоваться этой функцией, то помимо данных для параметров array и query мы ещё должны передать информацию о типах (для переменной T ). В первом примере мы передаём тип string , а во втором — number .

// передаём string в качестве типа include < string >(['igor', 'sasha', 'ira'], 'ira'); // true // передаём number в качестве типа include < number >([1, 3, 5], 7); // false 

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

�� Дженерики — переменные, через которые мы можем передавать тип.

Ещё о JavaScript

  • Type predicates в TypeScript на примере
  • Типы данных в JavaScript. Инструкция для начинающих
  • Живые и неживые коллекции в JavaScript

«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

Generics

Дженерики (generics) в языке программирования Java — это сущности, которые могут хранить в себе данные только определенного типа. Например, список элементов, в котором могут быть одни числа. Но не только: дженерик — обобщенный термин для разных структур.

Освойте профессию «Java-разработчик»

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

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

В разных источниках можно услышать про «тип-дженерик», «класс-дженерик» или «метод-дженерик». Это нормально, ведь обобщение и параметризация касаются всех этих сущностей, а generics — общий термин.

Для чего нужны дженерики

С дженериками работают программисты на Java. Без этой возможности писать код, который работает только с определенным видом данных, было сложнее. Существовало два способа, и оба неоптимальные:

  • указывать проверку типа вкоде. Например, получать данные — и сразу проверять, а если они не те, выдавать ошибку. Это помогло бы отсеять ненужные элементы. Но если бы класс понадобилось сделать более гибким, например, создать его вариацию для другого типа, его пришлось бы переписывать или копировать. Не получилось бы просто передать другой специальный параметр, чтобы тот же класс смог работать еще с каким-то типом;
  • полагаться на разработчиков. Например, оставлять в коде комментарий «Этот класс работает только с числами». Слишком велик риск, что кто-то не заметит комментарий и передаст в объект класса не те данные. И хорошо, если ошибка будет заметна сразу, а не уже на этапе тестирования.

Поэтому появились дженерики: они решают эту проблему, делают написание кода проще, а защиту от ошибок надежнее.

Профессия / 14 месяцев
Java-разработчик

Освойте востребованный язык

Group 1321314345 (4)

Как работают дженерики

Чтобы вернее понять принцип работы, нужно представлять, как устроены сущности в Java. Есть классы — это как бы «чертежи» будущих сущностей, описывающие, что они делают. И есть объекты — экземпляры классов, непосредственно существующие и работающие. Класс — как схема машины, объект — как машина.

Когда разработчик создает дженерик-класс, он приписывает к нему параметр в треугольных скобках — метку. К примеру, так:

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

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

Что такое raw types

В Java есть понятие raw types. Так называют дженерик-классы, из которых удалили параметр. То есть изначально класс описали как дженерик, но при создании объекта этого класса тип ему не передали. То есть что-то вроде myClass<> — тип не указан.

Дословно это название переводится как «сырые типы». Пользоваться ими сейчас в коммерческой разработке — чаще всего плохая практика. Но в мире все еще много старого кода, который написали до появления дженериков. Если такой код еще не успели переписать, в нем может быть очень много «сырых типов». Это надо учитывать, чтобы не возникало проблем с совместимостью.

Дженерики-классы и дженерики-методы

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

  • дженерик-классы (generic classes) это классы, «схемы» объектов с параметром. При создании объекта ему передается тип, с которым он будет работать;
  • дженерик-методы (generics methods) это методы, работающие по такому же принципу. Метод — это функция внутри объекта, то, что он может делать. Методу тип передается при вызове, сразу перед аргументами. Так можно создавать более универсальные функции и применять одну и ту же логику к данным разного типа.

Кстати, дженериками могут быть и встроенные классы или методы, и те, которые разработчик пишет самостоятельно. Например, встроенный ArrayList — список-массив — работает как дженерик.

Станьте Java-разработчиком
и создавайте сложные сервисы
на востребованном языке

Что будет, если передать дженерику не тот тип

Если объекту класса-дженерика передать не тот тип, который указали при его объявлении, он выдаст ошибку. Например, если в ходе работы экземпляра myClass в нем попытаются сохранить дробное число или даже строку, программа не скомпилируется. Вместо этого разработчик увидит ошибку: неверный тип.

Эта ошибка отличается от тех, которые возникнут, если не пользоваться дженериками. По ней сразу ясно, из-за чего она возникла и как можно ее исправить. Кроме того, она появляется сразу. Поэтому код становится легче отлаживать.

А если отправить «не тот» тип объекту без дженерика, действия с ним выполнятся с ошибкой. Но по этой ошибке не всегда очевидно, чем она вызвана. Худший вариант — код успешно запустится, но сработает неправильно: так ошибку будет найти еще сложнее.

Особенности дженериков

У дженериков есть несколько особенностей, о которых стоит знать при работе с ними. Если не учитывать эти детали, программировать будет как минимум менее удобно. А как максимум можно допустить ошибку и не понять, куда она закралась.

Выведение типа. Эта особенность касается объявления экземпляра класса, то есть создания объекта. Полная запись создания будет выглядеть так:

myClass objectForIntegers = new myClass();

objectForIntegers — это название объекта, оно может быть любым. То, что находится после знака «равно», — непосредственно команда «создать новый экземпляр класса».

Но полная запись очень громоздкая. Поэтому современные компиляторы Java способны на выведение типа — автоматическую его подстановку в записи после первого упоминания. То есть конструкцию myClass понадобится написать только один раз.

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

myClass objectForIntegers = new myClass<>();

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

Стирание типов. Важная деталь, которая касается работы дженериков, — они существуют только на этапе компиляции. В этом их суть: «не пропускать» данные ненужного типа в объект, а такие вещи определяет компилятор.

После компиляции код на Java превращается в байт-код. И на этом уровне никаких дженериков нет. myClass и myClass в байт-коде будут идентичны, просто с разными данными внутри.

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

О стирании типов важно помнить. Для запущенной программы в байт-коде дженериков не существует, и это может вызвать ошибки. Например, при сравнении myClass и myClass программа скажет, что они одинаковые. А иногда в объект в запущенном коде и вовсе получается передать данные другого типа.

«Дикие карты». Еще одна интересная и полезная особенность дженериков — так называемые wildcards, или «дикие карты». Это термин из спорта, означающий особое приглашение спортсмена на соревнование в обход правил. А в карточных играх так называют карты, которые можно играть вместо других, например джокера.

В основе wildcards в Java лежит такая же идея: изменить предустановленное поведение и сделать что-то в обход установленных рамок. Когда объявляется «дикая карта», в треугольных скобках вместо названия типа ставится вопросительный знак. Это означает, что сюда можно подставить любой тип.

Подставить wildcard можно не везде. Например, при создании класса это сделать не получится, а при объявлении объекта этого класса — получится. Чаще всего «дикую карту» используют при работе с переменными и с коллекциями.

Ограниченные «дикие карты». Кроме стандартной wildcard, существует еще несколько типов — ограниченные «дикие карты». С их помощью можно передать в объект данные не только конкретного типа, но и унаследованных от него — «потомков». Или же «предков» — типов, от которых был унаследован упомянутый.

Ограниченный wildcard описывается как вопросительный знак, за которым следует правило.

Есть два вида ограничений:

  • upper bounding — ограничение сверху. За вопросительным знаком следует слово extends и название типа. В такой дженерик можно передавать названный тип и его потомков;
  • lower bounding — ограничение снизу. Ситуация наоборот: за вопросительным знаком слово super и тип, а подставлять можно элементы этого типа и его предков.

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

Java-разработчик

Java уже 20 лет в мировом топе языков программирования. На нем создают сложные финансовые сервисы, стриминги и маркетплейсы. Освойте технологии, которые нужны для backend-разработки, за 14 месяцев.

картинка (67)

Статьи по теме:

Дженерики в TypeScript

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

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

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

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

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

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

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

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

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

Оператор keyof

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

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

type Payment =

Если нам потребуются ключи из типа 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 =

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

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 .

Интерфейсы тоже могут быть обобщёнными:

interface PaymentInfo

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

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

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

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

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

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

function identity(arg: boolean): boolean;

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

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

function identity(arg: T): T

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

const identity = (arg: T): T

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

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

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

Код на JavaScript:

class IdentityClass < constructor(value) < this.value = value; >getIdentity() < return this.value; >>

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

interface IdentityGetter

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

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

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

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

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

interface Lengthwise < length: number; >function getLength(arg: T): number

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

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

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

function getPropertyValue(obj: Obj, key: Key): 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

Нам нужно написать функцию, которая будет определять, является ли переданное животное объектом, реализующим интерфейс 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

Теперь 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 задачи разделены по сложности и пронумерованы. Под каждой есть ссылка на предлагаемые разработчиками решения — можете легко проверить себя.

  • Блог компании TINKOFF
  • Веб-разработка
  • JavaScript
  • TypeScript

Добавить комментарий

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