Перевели большую статью бывшего разработчика Amazon Web Services Хэ Чжэнхао и узнали, что такое иерархия типов в TypeScript и как они соотносятся между собой.
Статья рассчитана на читателей, которые уже знакомы с TypeScript. Изучить основы языка бесплатно можно на проекте Хекслета Code Basics.
Посмотрите на фрагменты кода TypeScript и попробуйте самостоятельно определить, есть ли в каждом из них ошибки согласования типов (если не получится — ничего страшного, мы все объясним):
Даже если вы уже работали с TypeScript, искать ошибки без подстановки кода в редактор и компилятора может быть сложно. Чтобы ориентироваться в типах TypeScript any
, unknown
, void
и never
, нужно понимать устройство системы типов. Давайте его разберем.
Содержание
- TypeScript: типы и дерево иерархии
- Номинативная и структурная типизация TypeScript
- Два способа проверить возможность присвоения или замены
- Верхний уровень иерархии
- Восходящее и нисходящее приведение типов
- Нижний уровень иерархии
- Типы в середине иерархии
- Ситуации, в которых TypeScript запрещает неявное восходящее приведение
TypeScript: типы и дерево иерархии
У каждого типа в TypeScript есть свое место в иерархии, которую можно представить в виде древовидной структуры. Она всегда состоит из одного родительского и одного дочернего узла. В иерархии типов родительскому узлу соответствует супертип, а дочернему узлу — подтип.
Одна из главных концепций объектно-ориентированного программирования — принцип наследования. Он устанавливает отношение «является» между дочерним классом и родительским классом. Если взять родительский класс «Транспортное средство» и дочерний класс «Автомобиль», между ними устанавливается отношение «Автомобиль является Транспортным средством».
Это правило не работает в обратном направлении: по логике, экземпляр родительского класса не является экземпляром дочернего класса. «Транспортное средство не является Автомобилем». В этом и заключается принцип наследования, который также применим к иерархии типов в TypeScript.
Согласно принципу подстановки Барбары Лисков, экземпляры класса «Транспортное средство» (супертип) можно заменить на экземпляры дочернего класса «Автомобиль» (подтип), и при этом программа продолжит работать правильно. Другими словами, если от типа «Транспортное средство» мы ждем определенное поведение, то и поведение подтипа «Автомобиль» не должно ему противоречить.
В TypeScript можно присваивать экземпляр подтипа экземпляру супертипа или заменять экземпляр подтипа экземпляром супертипа, но не наоборот.
Читайте также:
Как устроен TypeScript и зачем его используют
Номинативная и структурная типизация TypeScript
Существует два подхода к определению отношений между супертипами и подтипами.
Первый способ используется в большинстве распространенных языков со статической типизацией, таких как Java, и называется номинативной типизацией. В этом случае нужно явно указать, что тип является подтипом другого типа, например, class Foo extends Bar
.
TypeScript использует другой способ — структурную типизацию — и не требует явно указывать в коде взаимоотношение между типами. Экземпляр типа Foo
является подтипом Bar
, если в него входят все члены типа Bar
и дополнительные члены.
Чтобы выяснить, какой из типов является супертипом, а какой — подтипом, можно определить более строгий тип. Например, тип {name: string, age: number}
— это более строгий тип, чем {name: string}
, поскольку для каждого экземпляра первого типа требуется определить больше членов. Таким образом, тип {name: string, age: number}
является подтипом типа {name: string}
.
Два способа проверить возможность присвоения или замены
Прежде чем подробно рассмотреть иерархию типов TypeScript, уточним два способа проверки:
- Используя приведение типов, можно присвоить переменную одного типа переменной другого и посмотреть, не возникнет ли ошибка согласования типов.
- Используя наследование через ключевое слово
extends
, можно создать новый тип, который наследует структуру существующего типа:
Верхний уровень иерархии
Рассмотрим дерево иерархии типов.
В TypeScript существует два типа, которые могут выступать как супертипы всех остальных типов — это any
и unknown
.
Они принимают значение любого типа и таким образом включают в себя все остальные типы.
На схеме изображены не все TypeScript-типы. Информацию обо всех типах, которые TypeScript поддерживает в настоящее время, можно найти в статье о TypeScript.
Восходящее и нисходящее приведение типов
Существуют два вида приведения типов — восходящее и нисходящее.
Присваивание подтипа его супертипу называется восходящим приведением. По принципу подстановки Барбары Лисков, восходящее приведение типов безопасно, поэтому компилятор позволяет выполнить такое приведение неявно, без каких-либо вопросов.
Восходящее приведение типов можно представить как восхождение по дереву иерархии — замену более строгих (под)типов на более обобщенные супертипы.
Например, каждый тип string
является подтипом типа any
и типа unknown
. Поэтому типы можно присваивать следующим образом:
Обратное действие называется нисходящим приведением типов. Его можно представить как спуск по дереву иерархии — замену более обобщенных (супер)типов на более строгие подтипы.
В отличие от восходящего, нисходящее приведение типов небезопасно и в большинстве языков со строгой типизацией не может выполняться автоматически. Примером нисходящего приведения можно назвать присваивание переменных типов any
и unknown
типу string
:
Если присвоить unknown
типу string
, компилятор TypeScript выдает ошибку согласования типов, поскольку для нисходящего приведения необходимо явно обозначить обход модуля контроля типов.
При этом TypeScript легко согласится присвоить any
типу string
. Может показаться, что это противоречит указанному правилу.
Однако any
— это исключение. В TypeScript этот тип существует как лазейка, способ перейти в JavaScript. Это свидетельствует о доминирующей роли JavaScript как более гибкого языка. TypeScript представляет собой компромисс. Указанное исключение возникло не в результате ошибки проектирования, а из-за того, что фактическим языком выполнения кода является не TypeScript, а JavaScript.
Читайте также:
Как использовать аннотации типов в файлах JavaScript
Нижний уровень иерархии
В самом низу дерева иерархии находится тип never
, от которого не отходят никакие другие ветви.
Тип never
полностью противоположен типам верхнего уровня — any
и unknown
, которые могут принимать любые значения. never
является подтипом всех остальных типов и не принимает никакие значения, в том числе значения типа any
.
У типа never
должно быть неограниченное количество типов и членов, так как этот тип можно присвоить его супертипам или использовать для замены его супертипов. То есть всех других типов в системе TypeScript по принципу подстановки Барбары Лисков.
Например, наша программа должна выполняться правильно, если заменить тип number
или string
на never
. Поскольку never
является подтипом string
и number
, что не противоречит поведению, определенному супертипами.
С технической точки зрения это невозможно. Тип never
в TypeScript — это пустой тип, для которого нельзя получить значение во время выполнения. И ничего нельзя сделать, например, получить доступ к свойствам его экземпляров.
Классическим вариантом использования never
может быть ситуация, в которой нужно присвоить тип возвращаемому значению функции. Она гарантированно ничего не возвращает.
Если функция ничего не возвращает, это может быть по разным причинам:
- Функция может выбрасывать исключение на всех путях выполнения кода
- В функции может быть бесконечный цикл, который не позволяет функции завершиться до отключения всей системы, например, цикл событий.
Все эти сценарии возможны.
Может показаться, что тип присвоен неправильно: ведь если never
— это пустой тип, как можно присвоить его типу number
? Однако это возможно, поскольку компилятор знает, что наша функция ничего не возвращает, поэтому переменной number
не будет присвоено никакое значение. Типы существуют для обеспечения корректности данных во время выполнения кода. Если в это время значение не присваивается, и компилятор заранее это знает, типы не играют никакой роли.
Еще один способ создания типа never
— это пересечение двух несовместимых типов, например, {x: number} и {x: string}
.
У полученного типа есть определенные нюансы. Если несовместимые свойства являются разделяющими (условно говоря, если их значения относятся к типам литерала или объединениям типов литерала), весь тип преобразуется в never
. Эта функция появилась в TypeScript 3.9. Подробные сведения и обоснования приведены в этой статье.
Читайте также:
Гайд по Nest.js: что это такое и как написать свой первый код
Типы в середине иерархии
Мы уже рассмотрели типы, расположенные в верхней и нижней части дерева иерархии. Между ними есть другие стандартные и часто используемые типы, включая number
, string
, boolean
и составные типы.
Если есть общее понимание системы типов, то принцип работы промежуточных типов тоже будет очевиден:
- Можно присвоить тип
string literal
, например,let stringLiteral: 'hello' = 'hello'
типуstring
(восходящее приведение), но не наоборот (нисходящее приведение) - Можно присвоить переменную, содержащую объект типа с большим количеством свойств, объекту типа с меньшим количеством свойств, если типы существующих свойств совпадают (восходящее приведение), но не наоборот (нисходящее приведение)
- Или присвоить непустой объект пустому объекту:
Стоит отдельно рассмотреть еще один тип — void
, который часто путают с типом нижнего уровня — never
.
Во многих других языках программирования, таких как C++, void
используется как возвращаемый тип функции, который означает, что функция ничего не возвращает. Однако в TypeScript для ничего не возвращающей функции правильным типом возвращаемого значения будет never
.
Тип void
в TypeScript — это супертип для типа undefined
. TypeScript позволяет присваивать значение undefined
типу void
(восходящее приведение), но не наоборот (нисходящее приведение).
Это также можно проверить через ключевое слово extends
:
В JavaScript тип void
также используется как оператор, позволяющий проверить, соответствует ли следующее за оператором выражение типу undefined
, например, void 2 === undefined // true
.
В TypeScript тип void
указывает, что исполнитель функции не гарантирует тип возвращаемого значения, а только сообщает, что это значение не будет полезно для вызывающей стороны. В результате во время выполнения функция void
может вернуть значение другого типа (не undefined
), но вызывающая сторона не может использовать возвращаемое значение.
На первый взгляд может показаться, что это нарушает принцип подстановки Барбары Лисков, поскольку тип string
не является подтипом void
и не может заменить void
. Однако нужно обратить внимание, влияет ли эта ситуация на правильное выполнение программы. Получается, что, если вызывающая функция не использует возвращаемое значение функции void
(в чем и заключается назначение типа void
), то можно легко заменить ее на функцию, которая возвращает значение другого типа.
TypeScript применяет практичный подход и дополняет возможности работы с функциями, существующие в JavaScript. В JavaScript функции часто используются повторно, в новых условиях, при этом возвращаемые значения игнорируются.
Читайте также:
О релевантности принципов объектно-ориентированного программирования SOLID
Еще один способ применения типа void
— это его использование для аннотации this
при объявлении функции:
В результате this
нельзя использовать внутри функции.
Ситуации, в которых TypeScript запрещает неявное восходящее приведение
Таких ситуаций может быть две, но на практике они возникают редко:
Передача литеральных объектов напрямую функции
Присваивание литеральных объектов напрямую переменным с явно заданными типами
Продолжайте учиться:
На Хекслете есть несколько больших профессий, интенсивов и треков для джуниоров, мидлов и даже сеньоров: они позволят не только узнать новые технологии, но и прокачать уже существующие навыки
Алексей Покровский
3 года назад