до 80 000 ₽
Майские скидки до 80 000 ₽
Главная | Все статьи | Код

Как устроена система типов в TypeScript

Время чтения статьи ~13 минут
Как устроена система типов в TypeScript главное изображение

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

Читайте также: Как устроен TypeScript и зачем его используют

Номинативная и структурная типизация TypeScript

Существует два подхода к определению отношений между супертипами и подтипами.

Первый способ используется в большинстве распространенных языков со статической типизацией, таких как Java, и называется номинативной типизацией. В этом случае нужно явно указать, что тип является подтипом другого типа, например, class Foo extends Bar.

TypeScript использует другой способ — структурную типизацию — и не требует явно указывать в коде взаимоотношение между типами. Экземпляр типа Foo является подтипом Bar, если в него входят все члены типа Bar и дополнительные члены.

Чтобы выяснить, какой из типов является супертипом, а какой — подтипом, можно определить более строгий тип. Например, тип {name: string, age: number} — это более строгий тип, чем {name: string}, поскольку для каждого экземпляра первого типа требуется определить больше членов. Таким образом, тип {name: string, age: number} является подтипом типа {name: string}.

Два способа проверить возможность присвоения или замены

Прежде чем подробно рассмотреть иерархию типов TypeScript, уточним два способа проверки:

  1. Используя приведение типов, можно присвоить переменную одного типа переменной другого и посмотреть, не возникнет ли ошибка согласования типов.
  2. Используя наследование через ключевое слово 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 нельзя использовать внутри функции.

Ситуации, в которых TypeScript запрещает неявное восходящее приведение

Таких ситуаций может быть две, но на практике они возникают редко:

Передача литеральных объектов напрямую функции

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.

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

Посмотреть предложения Хекслета

Аватар пользователя Алексей Покровский
Алексей Покровский 06 декабря 2022
18
Рекомендуемые программы
профессия
от 6 300 ₽ в месяц
Разработка фронтенд-компонентов для веб-приложений
10 месяцев
с нуля
Старт 2 мая
профессия
от 6 300 ₽ в месяц
Разработка веб-приложений на Django
10 месяцев
с нуля
Старт 2 мая
профессия
от 6 183 ₽ в месяц
Ручное тестирование веб-приложений
4 месяца
с нуля
Старт 2 мая
профессия
от 6 300 ₽ в месяц
Разработка приложений на языке Java
10 месяцев
с нуля
Старт 2 мая
профессия
от 5 025 ₽ в месяц
новый
Сбор, анализ и интерпретация данных
9 месяцев
с нуля
Старт 2 мая
профессия
от 6 300 ₽ в месяц
Разработка веб-приложений на Laravel
10 месяцев
с нуля
Старт 2 мая
профессия
от 5 840 ₽ в месяц
Создание веб-приложений со скоростью света
5 месяцев
c опытом
Старт 2 мая
профессия
от 9 900 ₽ в месяц
Разработка фронтенд- и бэкенд-компонентов для веб-приложений
16 месяцев
с нуля
Старт 2 мая
профессия
от 6 300 ₽ в месяц
Разработка бэкенд-компонентов для веб-приложений
10 месяцев
с нуля
Старт 2 мая
профессия
новый
Автоматизированное тестирование веб-приложений на JavaScript
8 месяцев
c опытом
в разработке
Старт 2 мая
профессия
Верстка с использованием последних стандартов CSS
5 месяцев
с нуля
Старт в любое время