Перевели большую статью бывшего разработчика Amazon Web Services Хэ Чжэнхао и узнали, что такое иерархия типов в TypeScript и как они соотносятся между собой.
Статья рассчитана на читателей, которые уже знакомы с TypeScript. Изучить основы языка бесплатно можно на проекте Хекслета Code Basics.
Посмотрите на фрагменты кода TypeScript и попробуйте самостоятельно определить, есть ли в каждом из них ошибки согласования типов (если не получится — ничего страшного, мы все объясним):
// 1. any и unknown
let stringVariable: string = 'string'
let anyVariable: any
let unknownVariable: unknown
anyVariable = stringVariable
unknownVariable = stringVariable
stringVariable = anyVariable
stringVariable = unknownVariable
// 2. `never`
let stringVariable: string = 'string'
let anyVariable: any
let neverVariable: never
neverVariable = stringVariable
neverVariable = anyVariable
anyVariable = neverVariable
stringVariable = neverVariable
// 3. `void`, вариант 1
let undefinedVariable: undefined
let voidVariable: void
let unknownVariable: unknown
voidVariable = undefinedVariable
undefinedVariable = voidVariable
voidVariable = unknownVariable
// 4. `void`, вариант 2
function fn(cb: () => void): void {
return cb()
}
fn(() => 'string')
Даже если вы уже работали с TypeScript, искать ошибки без подстановки кода в редактор и компилятора может быть сложно. Чтобы ориентироваться в типах TypeScript any
, unknown
, void
и never
, нужно понимать устройство системы типов. Давайте его разберем.
У каждого типа в 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
, можно создать новый тип, который наследует структуру существующего типа:type *A* = string extends unknown? true : false; // true
type *B* = unknown extends string? true : false; // false
Рассмотрим дерево иерархии типов.
В TypeScript существует два типа, которые могут выступать как супертипы всех остальных типов — это any
и unknown
.
Они принимают значение любого типа и таким образом включают в себя все остальные типы.
На схеме изображены не все TypeScript-типы. Информацию обо всех типах, которые TypeScript поддерживает в настоящее время, можно найти в статье о TypeScript.
Существуют два вида приведения типов — восходящее и нисходящее.
Присваивание подтипа его супертипу называется восходящим приведением. По принципу подстановки Барбары Лисков, восходящее приведение типов безопасно, поэтому компилятор позволяет выполнить такое приведение неявно, без каких-либо вопросов.
Восходящее приведение типов можно представить как восхождение по дереву иерархии — замену более строгих (под)типов на более обобщенные супертипы.
Например, каждый тип string
является подтипом типа any
и типа unknown
. Поэтому типы можно присваивать следующим образом:
let string: string = 'foo'
let any: any = string // ✅ ⬆️восходящее приведение
let unknown: unknown = string // ✅ ⬆️восходящее приведение
Обратное действие называется нисходящим приведением типов. Его можно представить как спуск по дереву иерархии — замену более обобщенных (супер)типов на более строгие подтипы.
В отличие от восходящего, нисходящее приведение типов небезопасно и в большинстве языков со строгой типизацией не может выполняться автоматически. Примером нисходящего приведения можно назвать присваивание переменных типов any
и unknown
типу string
:
let any: any
let unknown: unknown
let stringA: string = any // ✅ ⬇️нисходящее приведение возможно с учетом особенностей типа `any`
let stringB: string = unknown // ❌ ⬇️нисходящее приведение
Если присвоить unknown
типу string
, компилятор TypeScript выдает ошибку согласования типов, поскольку для нисходящего приведения необходимо явно обозначить обход модуля контроля типов.
При этом TypeScript легко согласится присвоить any
типу string
. Может показаться, что это противоречит указанному правилу.
Однако any
— это исключение. В TypeScript этот тип существует как лазейка, способ перейти в JavaScript. Это свидетельствует о доминирующей роли JavaScript как более гибкого языка. TypeScript представляет собой компромисс. Указанное исключение возникло не в результате ошибки проектирования, а из-за того, что фактическим языком выполнения кода является не TypeScript, а JavaScript.
Читайте также: Как использовать аннотации типов в файлах JavaScript
В самом низу дерева иерархии находится тип never
, от которого не отходят никакие другие ветви.
Тип never
полностью противоположен типам верхнего уровня — any
и unknown
, которые могут принимать любые значения. never
является подтипом всех остальных типов и не принимает никакие значения, в том числе значения типа any
.
let any: any
let number: number = 5
let never: never = any // ❌ ⬇️нисходящее приведение
never = number // ❌ ⬇️нисходящее приведение
number = never // ✅ ⬆️восходящее приведение
У типа never
должно быть неограниченное количество типов и членов, так как этот тип можно присвоить его супертипам или использовать для замены его супертипов. То есть всех других типов в системе TypeScript по принципу подстановки Барбары Лисков.
Например, наша программа должна выполняться правильно, если заменить тип number
или string
на never
. Поскольку never
является подтипом string
и number
, что не противоречит поведению, определенному супертипами.
С технической точки зрения это невозможно. Тип never
в TypeScript — это пустой тип, для которого нельзя получить значение во время выполнения. И ничего нельзя сделать, например, получить доступ к свойствам его экземпляров.
Классическим вариантом использования never
может быть ситуация, в которой нужно присвоить тип возвращаемому значению функции. Она гарантированно ничего не возвращает.
Если функция ничего не возвращает, это может быть по разным причинам:
Все эти сценарии возможны.
function fnThatNeverReturns(): never {
throw 'Функция ничего не возвращает'
}
const number: number = fnThatNeverReturns() // ✅ ⬆️восходящее приведение
Может показаться, что тип присвоен неправильно: ведь если never
— это пустой тип, как можно присвоить его типу number
? Однако это возможно, поскольку компилятор знает, что наша функция ничего не возвращает, поэтому переменной number
не будет присвоено никакое значение. Типы существуют для обеспечения корректности данных во время выполнения кода. Если в это время значение не присваивается, и компилятор заранее это знает, типы не играют никакой роли.
Еще один способ создания типа never
— это пересечение двух несовместимых типов, например, {x: number} и {x: string}
.
type Foo = {
name: string,
age: number
}
type Bar = {
name: number,
age: number
}
type Baz = Foo & Bar
const a: Baz = {age: 12, name:'foo'} // ❌ Тип 'string' не может быть присвоен типу 'never'.
У полученного типа есть определенные нюансы. Если несовместимые свойства являются разделяющими (условно говоря, если их значения относятся к типам литерала или объединениям типов литерала), весь тип преобразуется в never
. Эта функция появилась в TypeScript 3.9. Подробные сведения и обоснования приведены в этой статье.
Читайте также: Гайд по Nest.js: что это такое и как написать свой первый код
Мы уже рассмотрели типы, расположенные в верхней и нижней части дерева иерархии. Между ними есть другие стандартные и часто используемые типы, включая number
, string
, boolean
и составные типы.
Если есть общее понимание системы типов, то принцип работы промежуточных типов тоже будет очевиден:
string literal
, например, let stringLiteral: 'hello' = 'hello'
типу string
(восходящее приведение), но не наоборот (нисходящее приведение)type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}
type *A* = UserWithEmail extends UserWithoutEmail ? true : false // true ✅ ⬆️восходящее приведение
js
const emptyObject: {} = {foo: 'bar'} // ✅ ⬆️восходящее приведение
Стоит отдельно рассмотреть еще один тип — void
, который часто путают с типом нижнего уровня — never
.Во многих других языках программирования, таких как C++, void
используется как возвращаемый тип функции, который означает, что функция ничего не возвращает. Однако в TypeScript для ничего не возвращающей функции правильным типом возвращаемого значения будет never
.
Тип void
в TypeScript — это супертип для типа undefined
. TypeScript позволяет присваивать значение undefined
типу void
(восходящее приведение), но не наоборот (нисходящее приведение).
Это также можно проверить через ключевое слово extends
:
type *A* = undefined extends void ? true : false; // true
type *B* = void extends undefined ? true : false; // false
В JavaScript тип void
также используется как оператор, позволяющий проверить, соответствует ли следующее за оператором выражение типу undefined
, например, void 2 === undefined // true
.
В TypeScript тип void
указывает, что исполнитель функции не гарантирует тип возвращаемого значения, а только сообщает, что это значение не будет полезно для вызывающей стороны. В результате во время выполнения функция void
может вернуть значение другого типа (не undefined
), но вызывающая сторона не может использовать возвращаемое значение.
function fn(cb: () => void): void {
return cb()
}
fn(() => 'string')
На первый взгляд может показаться, что это нарушает принцип подстановки Барбары Лисков, поскольку тип string
не является подтипом void
и не может заменить void
. Однако нужно обратить внимание, влияет ли эта ситуация на правильное выполнение программы. Получается, что, если вызывающая функция не использует возвращаемое значение функции void
(в чем и заключается назначение типа void
), то можно легко заменить ее на функцию, которая возвращает значение другого типа.
TypeScript применяет практичный подход и дополняет возможности работы с функциями, существующие в JavaScript. В JavaScript функции часто используются повторно, в новых условиях, при этом возвращаемые значения игнорируются.
Читайте также: О релевантности принципов объектно-ориентированного программирования SOLID
Еще один способ применения типа void
— это его использование для аннотации this
при объявлении функции:
function doSomething(this: void, value: string) {
this // void
}
В результате this
нельзя использовать внутри функции.
Таких ситуаций может быть две, но на практике они возникают редко:
Передача литеральных объектов напрямую функции
function fn(obj: {name: string}) {}
fn({name: 'foo', key: 1}) // ❌ Литеральный объект может указывать только известные свойства, а свойство 'key' не существует для типа '{ name: string; }'
Присваивание литеральных объектов напрямую переменным с явно заданными типами
type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}
let userB: UserWithoutEmail = {name: 'foo', email: 'foo@gmail.com'} // ❌ Тип { name: string; email: string; } не может быть присвоен типу UserWithoutEmail.
Продолжайте учиться: На Хекслете есть несколько больших профессий, интенсивов и треков для джуниоров, мидлов и даже сеньоров: они позволят не только узнать новые технологии, но и прокачать уже существующие навыки