Skip to content

Latest commit

 

History

History
5416 lines (3984 loc) · 237 KB

ts.md

File metadata and controls

5416 lines (3984 loc) · 237 KB

TypeScript Handbook 🤘

На главную

Содержание

Основы

Каждое значение в JavaScript при выполнении над ним каких-либо операций ведет себя определенным образом. Это может звучать несколько абстрактно, но, в качестве примера, попробуем выполнить некоторые операции над переменной message:

// Получаем доступ к свойству `toLowerCase`
// и вызываем его
message.toLowerCase()

// Вызываем `message`
message()

На первой строке мы получаем доступ к свойству toLowerCase и вызываем его. На второй строке мы пытаемся вызвать message.

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

  • Является ли переменная message вызываемой?
  • Имеет ли она свойство toLowerCase?
  • Если имеет, является ли toLowerCase вызываемым?
  • Если оба этих значения являются вызываемыми, то что они возвращают?

Ответы на эти вопросы, как правило, хранятся в нашей памяти, поэтому остается только надеяться, что мы все помним правильно.

Допустим, message была определена следующим образом:

const message = 'Hello World'

Как вы, наверное, догадались, при запуске message.toLowerCase() мы получим ту же строку, только в нижнем регистре.

Что насчет второй строки кода? Если вы знакомы с JS, то знаете, что в этом случае будет выброшено исключение:

TypeError: message is not a function
// Ошибка типа: message - это не функция

Было бы здорово, если бы мы имели возможность избегать подобных ошибок.

При запуске нашего кода, способ, с помощью которого движок JS определяет, что делать, заключается в выяснении типа (type) значения - каким поведением и возможностями он обладает. На это намекает TypeError - она говорит, что строка 'Hello World' не может вызываться как функция.

Для некоторых значений, таких как примитивы string и number, мы можем определить их тип во время выполнения кода (runtime) с помощью оператора typeof. Но для других значений, таких как функции, соответствующий механизм для определения типов во время выполнения отсутствует. Например, рассмотрим следующую функцию:

function fn(x) {
  return x.flip()
}

Читая этот код, мы можем сделать вывод, что функция будет работать только в случае передачи ей объекта с вызываемым свойством flip, но JS не обладает этой информацией. Единственным способом определить, что делает fn с определенным значением, в чистом JS является вызов этой функции. Такой вид поведения затрудняет предсказание поведения кода во время его написания.

В данном случае тип - это описание того, какие значения могут передаваться в fn, а какие приведут к возникновению ошибки. JS - это язык с динамической (слабой) типизацией - мы не знаем, что произойдет, до выполнения кода.

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

Проверка статических типов

Вернемся к TypeError, которую мы получили, пытаясь вызвать string как функцию. Никто не любит получать ошибки или баги (bugs) при выполнении кода.

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

const message = 'Hello!'

message()
// This expression is not callable. Type 'String' has no call signatures.
// Данное выражение не является вызываемым. Тип 'String' не обладает сигнатурами вызова

При использовании TS, мы получаем ошибку перед выполнением кода (на этапе компиляции).

Ошибки, не являющиеся исключениями

До сих пор мы говорили об ошибках времени выполнения - случаях, когда движок JS сообщает нам о том, что произошло нечто с его точки зрения бессмысленное. Спецификация ECMAScript содержит конкретные инструкции относительно того, как должен вести себя код при столкновении с чем-то неожиданным.

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

const user = {
  name: 'John',
  age: 30
}

user.location // undefined

В TS это, как и ожидается, приводит к ошибке:

const user = {
  name: 'John',
  age: 30
}

user.location
// Property 'location' does not exist on type '{ name: string; age: number; }'.
// Свойства 'location' не существует в типе...

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

Например:

  • опечатки
const announcement = 'Hello World!'

// Как быстро вы заметите опечатку?
announcement.toLocaleLowercase()
announcement.toLocalLowerCase()

// Вероятно, мы хотели написать это
announcement.toLocaleLowerCase()
  • функции, которые не были вызваны
function flipCoin() {
  // Должно было быть `Math.random()`
  return Math.random < 0.5
  // Operator '<' cannot be applied to types '() => number' and 'number'.
  // Оператор '<' не может быть применен к типам...
}
  • или логические ошибки
const value = Math.random() < 0.5 ? 'a' : 'b'
if (value !== 'a') {
  // ...
} else if (value === 'b') {
  // This condition will always return 'false' since the types 'a' and 'b' have no overlap.
  // Данное условие будет всегда возвращать 'false', поскольку типы 'a' и 'b' не пересекаются
  // Упс, недостижимый участок кода
}

Типы, интегрированные в среду разработки

TS защищает нас от совершения ошибок. Как он это делает? Все просто. Поскольку TS обладает информацией о системе типов, используемых в нашем коде, он начинает предполагать (делать вывод относительно того), какое свойство мы хотим использовать. Это означает, что TS показывает сообщения об ошибках и варианты завершения в процессе написания кода. Редактор кода, поддерживающий TS, также может предлагать способы "быстрого исправления" ошибок, предоставлять средства для автоматического рефакторинга, т.е. для легкой реорганизации кода, а также для полезной навигации, например, для быстрого перехода к определениям переменных или для поиска ссылок на переменную и т.д.

tsc, компилятор TS

Для начала установим tsc:

yarn global add tsc
# или
npm i -g tsc

Создадим файл hello.ts:

// Приветствуем всех собравшихся
console.log('Hello World!')

И скомпилируем (преобразуем) его в JS:

tsc hello.ts

Отлично. Мы не получили сообщений об ошибках в терминале, следовательно, компиляция прошла успешно. Заглянем в текущую директорию. Мы видим, что там появился файл hello.js. Этот файл является идентичным по содержанию файлу hello.ts, поскольку в данном случае TS нечего было преобразовывать. Кроме того, компилятор старается сохранять код максимально близким к тому, что написал разработчик.

Теперь попробуем вызвать ошибку. Перепишем hello.ts:

function greet(person, date) {
  console.log(`Hello, ${person}! Today is ${date}.`)
}

greet('John')

Если мы снова запустим tsc hello.ts, то получим ошибку:

Expected 2 arguments, but got 1. Ожидалось 2 аргумента, а получен 1

TS сообщает нам о том, что мы забыли передать аргумент в функцию greet, и он прав.

Компиляция с ошибками

Вы могли заметить, что после компиляции кода, содержащего ошибку, файл hello.js все равно обновился. Это объясняется тем, что TS считает вас умнее себя. Это также не мешает работающему JS-коду, при наличии некоторых ошибок, связанных с типами, благополучно работать дальше при постепенном переносе проекта на TS. Однако, если вы хотите, чтобы TS был более строгим, то можете указать флаг --noEmitOnError. Попробуйте снова изменить hello.ts и скомпилировать его с помощью такой команды:

tsc --noEmitOnError hello.ts

Вы увидите, что hello.js больше не обновляется.

Явные типы

Давайте отредактируем код и сообщим TS, что person - это string, а date - объект Date. Мы также вызовем метод toDateString() на date:

function greet(person: string, date: Date) {
  console.log(`Hello, ${person}! Today is ${date.toDateString().}`)
}

То, что мы сделали, называется добавлением аннотаций типа (type annotations) к person и date для описания того, с какими типами значений может вызываться greet.

После этого TS будет сообщать нам о неправильных вызовах функции, например:

function greet(person: string, date: Date) {
  console.log(`Hello, ${person}! Today is ${date.toDateString()}.`)
}

greet('John', Date())
// Argument of type 'string' is not assignable to parameter of type 'Date'.
// Аргумент типа 'string' не может быть присвоен параметру типа 'Date'

Вызов Date() возвращает строку. Для того, чтобы получить объект Date, следует вызвать new Date():

greet('John', new Date())

Во многих случаях нам не нужно явно аннотировать типы, поскольку TS умеет предполагать (infer) тип или делать вывод относительно типа на основе значения:

const msg = 'Hello!'
// const msg: string

Удаление типов

Давайте скомпилируем функцию greet в JS с помощью tsc. Вот что мы получаем:

'use strict'
function greet(person, date) {
  console.log('Hello ' + person + '! Today is ' + date.toDateString() + '.')
}
greet('John', new Date())

:::note Обратите внимание на две вещи

  1. Наши параметры person и date больше не имеют аннотаций типа.
  2. Наша "шаблонная строка" - строка, в которой используются обратные кавычки (``) - была преобразована в обычную строку с конкатенациями (+).

:::

Что касается первого пункта, то все дело в том, что аннотации типа не являются частью JS (или ECMAScript, если быть точнее), поэтому для того, чтобы преобразованный JS мог выполняться в браузере, они полностью удаляются из кода, как и любые другие специфичные для TS вещи.

Понижение уровня кода

Процесс, который часто называют понижением уровня кода (downleveling), состоит в преобразовании кода в код более старой версии, например, JS-кода, соответствующего спецификации ECMAScript 2015 (ES6), в код, соответствующий спецификации ECMAScript 3 (ES3). Шаблонные литералы (или шаблонные строки) были представлены в ES6, а TS по умолчанию преобразует код в ES3, поэтому наша шаблонная строка превратилась в обычную строку с объединениями. Для изменения спецификации, которой должен соответствовать компилируемый код, используется флаг --target. Например, команда tsc --target es2015 hello.ts оставит нашу строку неизменной.

Строгость

Строгость проверок, выполняемых TS, определяется несколькими флагами. Флаг --strict или настройка "strict": true в tsconfig.json включает максимальную строгость. Двумя другими главными настройками, определяющими строгость проверок, являются noImplicitAny и strictNullChecks.

  • noImplicitAny - когда TS не может сделать точный вывод о типе значения, он присваивает такому значению наиболее мягкий тип any. Данный тип означает, что значением переменной может быть что угодно. Однако, использование данного типа противоречит цели использования TS. Использование флага noImplicitAny или соответствующей настройки приводит к тому, что при обнаружении переменной с неявным типом any выбрасывается исключение
  • strictNullChecks - по умолчанию значения null и undefined могут присваиваться любым другим типам. Это может облегчить написание кода в некоторых ситуациях, но также часто приводит к багам, если мы забыли их правильно обработать. Флаг strictNullChecks или соответствующая настройка делает обработку null и undefined более явной и избавляет нас от необходимости беспокоиться о том, что мы забыли их обработать

Типы на каждый день

Примитивы: string, number и boolean

В JS часто используется 3 примитива: string, number и boolean. Каждый из них имеет соответствующий тип в TS:

  • string представляет строковые значения, например, 'Hello World'
  • number предназначен для чисел, например, 42. JS не различает целые числа и числа с плавающей точкой (или запятой), поэтому не существует таких типов, как int или float - только number
  • boolean - предназначен для двух значений: true и false

:::note Обратите внимание

Типы String, Number и Boolean (начинающиеся с большой буквы) являются легальными и ссылаются на специальные встроенные типы, которые, однако, редко используются в коде. Для типов всегда следует использовать string, number или boolean.

:::

Массивы

Для определения типа массива [1, 2, 3] можно использовать синтаксис number[]; такой синтаксис подходит для любого типа (например, string[] - это массив строк и т.д.). Также можно встретить Array<number>, что означает тоже самое. Такой синтаксис, обычно, используется для определения общих типов или дженериков (generics).

:::note Обратите внимание

[number] - это другой тип, кортеж (tuple).

:::

any

TS предоставляет специальный тип any, который может использоваться для отключения проверки типов:

let obj: any = { x: 0 }
// Ни одна из строк ниже не приведет к возникновению ошибки на этапе компиляции
// Использование `any` отключает проверку типов
// Использование `any` означает, что вы знакомы со средой выполнения кода лучше, чем `TS`
obj.foo()
obj()
obj.bar = 100
obj = 'hello'
const n: number = obj

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

noImplicitAny

При отсутствии определения типа и когда TS не может предположить его на основании контекста, неявным типом значение становится any.

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

Аннотации типа для переменных

При объявлении переменной с помощью const, let или var опционально можно определить ее тип:

const myName: string = 'John'

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

// В аннотации типа нет необходимости - `myName` будет иметь тип `string`
const myName = 'John'

Функции

В JS функции, в основном, используются для работы с данными. TS позволяет определять типы как для входных (input), так и для выходных (output) значений функции.

Аннотации типа параметров

При определении функции можно указать, какие типы параметров она принимает:

function greet(name: string) {
  console.log(`Hello, ${name.toUpperCase()}!`)
}

Вот что произойдет при попытке вызвать функцию с неправильным аргументом:

greet(42)
// Argument of type 'number' is not assignable to parameter of type 'string'. Аргумент типа 'number' не может быть присвоен параметру типа 'string'

:::note Обратите внимание

Количество передаваемых аргументов будет проверяться даже при отсутствии аннотаций типа параметров.

:::

Аннотация типа возвращаемого значения

Также можно аннотировать тип возвращаемого функцией значения:

function getFavouriteNumber(): number {
  return 26
}

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

Анонимные функции

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

Вот пример:

// Аннотации типа отсутствуют, но это не мешает `TS` обнаруживать ошибки
const names = ['Alice', 'Bob', 'John']

// Определение типов на основе контекста вызова функции
names.forEach(function (s) {
  console.log(s.toUppercase())
  // Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'? Свойства 'toUppercase' не существует в типе 'string'. Вы имели ввиду 'toUpperCase'?
})

// Определение типов на основе контекста также работает для стрелочных функций
names.forEach((s) => {
  console.log(s.toUppercase())
  // Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'?
})

Несмотря на отсутствие аннотации типа для s, TS использует типы функции forEach, а также предполагаемый тип массива для определения типа s. Этот процесс называется определением типа на основе контекста (contextual typing).

Типы объекта

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

function printCoords(pt: { x: number; y: number }) {
  console.log(`Значение координаты 'x': ${pt.x}`)
  console.log(`Значение координаты 'y': ${pt.y}`)
}

printCoords({ x: 3, y: 7 })

Для разделения свойств можно использовать , или ;. Тип свойства является опциональным. Свойство без явно определенного типа будет иметь тип any.

Опциональные свойства

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

function printName(obj: { first: string; last?: string }) {
  // ...
}
// Обе функции скомпилируются без ошибок
printName({ first: 'John' })
printName({ first: 'Jane', last: 'Air' })

В JS при доступе к несуществующему свойству возвращается undefined. По этой причине, при чтении опционального свойства необходимо выполнять проверку на undefined:

function printName(obj: { first: string; last?: string }) {
  // Ошибка - приложение может сломаться, если аргумент `last` не будет передан в функцию
  console.log(obj.last.toUpperCase()) // Object is possibly 'undefined'. Потенциальным значением объекта является 'undefined'

  if (obj.last !== undefined) {
    // Теперь все в порядке
    console.log(obj.last.toUpperCase())
  }

  // Безопасная альтернатива, использующая современный синтаксис `JS` - оператор опциональной последовательности (`?.`)
  console.log(obj.last?.toUpperCase())
}

Объединения (unions)

:::note Обратите внимание

В литературе, посвященной TS, union, обычно, переводится как объединение, но фактически речь идет об альтернативных типах, объединенных в один тип.

:::

Определение объединения

Объединение - это тип, сформированный из 2 и более типов, представляющий значение, которое может иметь один из этих типов. Типы, входящие в объединение, называются членами (members) объединения.

Реализуем функцию, которая может оперировать строками или числами:

function printId(id: number | string) {
  console.log(`Ваш ID: ${id}`)
}

// OK
printId(101)
// OK
printId('202')
// Ошибка
printId({ myID: 22342 })
// Argument of type '{ myID: number }' is not assignable to parameter of type 'string | number'. Type '{ myID: number }' is not assignable to type 'number'. Аргумент типа '{ myID: number }' не может быть присвоен параметру типа 'string | number'. Тип '{ myID: number }' не может быть присвоен типу 'number'

Работа с объединениями

В случае с объединениями, TS позволяет делать только такие вещи, которые являются валидными для каждого члена объединения. Например, если у нас имеется объединение string | number, мы не сможем использовать методы, которые доступны только для string:

function printId(id: number | string) {
  console.log(id.toUpperCase())
  // Property 'toUpperCase' does not exist on type 'string | number'. Property 'toUpperCase' does not exist on type 'number'.
}

Решение данной проблемы заключается в сужении (narrowing) объединения. Например, TS знает, что только для string оператор typeof возвращает 'string':

function printId(id: number | string) {
  if (typeof id === 'string') {
    // В этой ветке `id` имеет тип 'string'
    console.log(id.toUpperCase())
  } else {
    // А здесь `id` имеет тип 'number'
    console.log(id)
  }
}

Другой способ заключается в использовании функции, такой как Array.isArray:

function welcomePeople(x: string[] | string) {
  if (Array.isArray(x)) {
    // Здесь `x` - это 'string[]'
    console.log('Привет, ' + x.join(' и '))
  } else {
    // Здесь `x` - 'string'
    console.log('Добро пожаловать, одинокий странник ' + x)
  }
}

В некоторых случаях все члены объединения будут иметь общие методы. Например, и массивы, и строки имеют метод slice. Если каждый член объединения имеет общее свойство, необходимость в сужении отсутствует:

function getFirstThree(x: number[] | string) {
  return x.slice(0, 3)
}

Синонимы типов (type aliases)

Что если мы хотим использовать один и тот же тип в нескольких местах? Для этого используются синонимы типов:

type Point = {
  x: number
  y: number
}

// В точности тоже самое, что в приведенном выше примере
function printCoords(pt: Point) {
  console.log(`Значение координаты 'x': ${pt.x}`)
  console.log(`Значение координаты 'y': ${pt.y}`)
}

printCoords({ x: 3, y: 7 })

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

type ID = number | string

:::note Обратите внимание

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

:::

type UserInputSanitizedString = string

function sanitizeInput(str: string): UserInputSanitizedString {
  return sanitize(str)
}

// Создаем "обезвреженный" инпут
let userInput = sanitizeInput(getInput())

// По-прежнему имеем возможность изменять значение переменной
userInput = 'new input'

Интерфейсы

Определение интерфейса - это другой способ определения типа объекта:

interface Point {
  x: number
  y: number
}

function printCoords(pt: Point) {
  console.log(`Значение координаты 'x': ${pt.x}`)
  console.log(`Значение координаты 'y': ${pt.y}`)
}

printCoords({ x: 3, y: 7 })

TS иногда называют структурно типизированной системой типов (structurally typed type system) - TS заботит лишь соблюдение структуры значения, передаваемого в функцию printCoords, т.е. содержит ли данное значение ожидаемые свойства.

Разница между синонимами типов и интерфейсами

Синонимы типов и интерфейсы очень похожи. Почти все возможности interface доступны в type. Ключевым отличием между ними является то, что type не может быть повторно открыт для добавления новых свойств, в то время как interface всегда может быть расширен.

Пример расширения интерфейса:

interface Animal {
  name: string
}

interface Bear extends Animal {
  honey: boolean
}

const bear = getBear()
bear.name
bear.honey

Пример расширения типа с помощью пересечения (intersection):

type Animal {
  name: string
}

type Bear = Animal & {
  honey: boolean
}

const bear = getBear()
bear.name
bear.honey

Пример добавления новых полей в существующий интерфейс:

interface Window {
  title: string
}

interface Window {
  ts: TypeScriptAPI
}

const src = 'const a = 'Hello World''
window.ts.transpileModule(src, {})

Тип не может быть изменен после создания:

type Window = {
  title: string
}

type Window = {
  ts: TypeScriptAPI
}
// Ошибка: повторяющийся идентификатор 'Window'.

Общее правило: используйте interface до тех пор, пока вам не понадобятся возможности type.

Утверждение типа (type assertion)

В некоторых случаях мы знаем о типе значения больше, чем TS.

Например, когда мы используем document.getElementById, TS знает лишь то, что данный метод возвращает какой-то HTMLElement, но мы знаем, например, что будет возвращен HTMLCanvasElement. В этой ситуации мы можем использовать утверждение типа для определения более конкретного типа:

const myCanvas = document.getElementById('main_canvas') as HTMLCanvasElement

Для утверждения типа можно использовать другой синтаксис (е в TSX-файлах):

const myCanvas = <HTMLCanvasElement>document.getElementById('main_canvas')

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

const x = 'hello' as number
// Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
// Преобразование типа 'string' в тип 'number' может быть ошибкой, поскольку эти типы не перекрываются. Если это было сделано намерено, то выражение сначала следует преобразовать в 'unknown'

Иногда это правило может быть слишком консервативным и мешать выполнению более сложных валидных преобразований. В этом случае можно использовать двойное утверждение: сначала привести тип к any (или unknown), затем к нужному типу:

const a = expr as any as T

Литеральные типы (literal types)

В дополнение к общим типам string и number, мы можем ссылаться на конкретные строки и числа, находящиеся на определенных позициях.

Вот как TS создает типы для литералов:

let changingString = 'Hello World'
changingString = 'Olá Mundo'
// Поскольку `changingString` может представлять любую строку, вот
// как TS описывает ее в системе типов
changingString
// let changingString: string

const constantString = 'Hello World'
// Поскольку `constantString` может представлять только указанную строку, она
// имеет такое литеральное представление типа
constantString
// const constantString: 'Hello World'

Сами по себе литеральные типы особой ценности не представляют:

let x: 'hello' = 'hello'
// OK
x = 'hello'
// ...
x = 'howdy'
// Type '"howdy"' is not assignable to type '"hello"'.

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

function printText(s: string, alignment: 'left' | 'right' | 'center') {
  // ...
}
printText('Hello World', 'left')
printText("G'day, mate", 'centre')
// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

Числовые литеральные типы работают похожим образом:

function compare(a: string, b: string): -1 | 0 | 1 {
  return a === b ? 0 : a > b ? 1 : -1
}

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

interface Options {
  width: number
}
function configure(x: Options | 'auto') {
  // ...
}
configure({ width: 100 })
configure('auto')
configure('automatic')
// Argument of type '"automatic"' is not assignable to parameter of type 'Options | "auto"'.

Предположения типов литералов

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

const obj = { counter: 0 }
if (someCondition) {
  obj.counter = 1
}

TS не будет считать присвоение значения 1 полю, которое раньше имело значение 0, ошибкой. Это объясняется тем, что TS считает, что типом obj.counter является number, а не 0.

Тоже самое справедливо и в отношении строк:

const req = { url: 'https://example.com', method: 'GET' }
handleRequest(req.url, req.method)
// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

В приведенном примере предположительный типом req.method является string, а не 'GET'. Поскольку код может быть вычислен между созданием req и вызовом функции handleRequest, которая может присвоить req.method новое значение, например, GUESS, TS считает, что данный код содержит ошибку.

Существует 2 способа решить эту проблему.

  1. Можно утвердить тип на каждой позиции:
// Изменение 1
const req = { url: 'https://example.com', method: 'GET' as 'GET' }
// Изменение 2
handleRequest(req.url, req.method as 'GET')
  1. Для преобразования объекта в литерал можно использовать as const:
const req = { url: 'https://example.com', method: 'GET' } as const
handleRequest(req.url, req.method)

null и undefined

В JS существует два примитивных значения, сигнализирующих об отсутствии значения: null и undefined. TS имеет соответствующие типы. То, как эти типы обрабатываются, зависит от настройки strictNullChecks (см. часть 1).

Оператор утверждения ненулевого значения (non-null assertion operator)

TS предоставляет специальный синтаксис для удаления null и undefined из типа без необходимости выполнения явной проверки. Указание ! после выражения означает, что данное выражение не может быть нулевым, т.е. иметь значение null или undefined:

function liveDangerously(x?: number | undefined) {
  // Ошибки не возникает
  console.log(x!.toFixed())
}

Перечисления (enums)

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

Редко используемые примитивы

bigint

Данный примитив используется для представления очень больших целых чисел BigInt:

// Создание `bigint` с помощью функции `BigInt`
const oneHundred: bigint = BigInt(100)

// Создание `bigint` с помощью литерального синтаксиса
const anotherHundred: bigint = 100n

Подробнее о BigInt можно почитать здесь.

symbol

Данный примитив используется для создания глобально уникальных ссылок с помощью функции Symbol():

const firstName = Symbol('name')
const secondName = Symbol('name')

if (firstName === secondName) {
  // This condition will always return 'false' since the types 'typeof firstName' and 'typeof secondName' have no overlap.
  // Символы `firstName` и `lastName` никогда не будут равными
}

Подробнее о символах можно почитать здесь.

Сужение типов

Предположим, что у нас имеется функция под названием padLeft:

function padLeft(padding: number | string, input: string): string {
  throw new Error('Еще не реализовано!')
}

Если padding - это number, значит, мы хотим добавить указанное количество пробелов перед input. Если padding - это string, значит, мы просто хотим добавить padding перед input. Попробуем реализовать логику, когда padLeft принимает number для padding:

function padLeft(padding: number | string, input: string): string {
  return new Array(padding + 1).join(' ') + input
  // Operator '+' cannot be applied to types 'string | number' and 'number'.
  // Оператор '+' не может быть применен к типам 'string | number'
}

Мы получаем ошибку. TS предупреждает нас о том, что добавление number к number | string может привести к неожиданному результату, и он прав. Другими словами, мы должны проверить тип padding перед выполнением каких-либо операций с ним:

function padLeft(padding: number | string, input: string): string {
  if (typeof padding === 'number') {
    return new Array(padding + 1).join(' ') + input
  }
  return padding + input
}

Выражение typeof padding === 'number' называется защитником или предохранителем типа (type guard). А процесс приведения определенного типа к более конкретной версии с помощью защитников типа и присвоений называется сужением типа (narrowing).

function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    return new Array(padding + 1).join(' ') + input
    // (parameter) padding: number
  }
  return padding + input
  // (parameter) padding: string
}

Для сужения типов может использоваться несколько конструкций.

Защитник типа typeof

Оператор typeof возвращает одну из следующих строк:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

Рассмотрим интересный пример:

function printAll(strs: string | string[] | null) {
  if (typeof strs === 'object') {
    for (const s of strs) {
      // Object is possibly 'null'.
      // Потенциальным значением объекта является 'null'
      console.log(s)
    }
  } else if (typeof strs === 'string') {
    console.log(strs)
  } else {
    // ...
  }
}

В функции printAll мы пытаемся проверить, является ли переменная strs объектом (массивом). Но, поскольку выражение typeof null возвращает object (по историческим причинам), мы получаем ошибку.

Таким образом, в приведенном примере мы выполнили сужение к string[] | null вместо желаемого string[].

Проверка на истинность (truthiness narrowing)

В JS мы можем использовать любые выражения в условиях, инструкциях &&, ||, if, приведении к логическому значению с помощью ! и т.д. Например, в инструкции if условие не всегда должно иметь тип boolean:

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `В сети находится ${numUsersOnline} человек!`
  }
  return 'Здесь никого нет :('
}

В JS конструкции типа if преобразуют условия в логические значения и выбирают ветку (с кодом для выполнения) в зависимости от результата (true или false). Значения

  • 0
  • NaN
  • "" (пустая строка)
  • 0n (bigint-версия нуля)
  • null
  • undefined

являются ложными, т.е. преобразуются в false, остальные значения являются истинными, т.е. преобразуются в true. Явно преобразовать значение в логическое можно с помощью функции Boolean или с помощью двойного отрицания (!!):

// оба варианта возвращают `true`
Boolean('hello')
!!'world'

Данная техника широко применяется для исключения значений null и undefined. Применим ее к нашей функции printAll:

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === 'object') {
    for (const s of strs) {
      console.log(s)
    }
  } else if (typeof strs === 'string') {
    console.log(strs)
  }
}

Теперь ошибки не возникает, поскольку мы проверяем, что strs является истинным. Это защищает нас от таких ошибок как:

TypeError: null is not iterable
Ошибка типа: null не является итерируемой (перебираемой) сущностью

:::note Обратите внимание

Проверка примитивов на истинность также подвержена подобным ошибкам. Рассмотрим другую реализацию printAll:

:::

function printAll(strs: string | string[] | null) {
  // !!!
  //  НЕ НАДО ТАК ДЕЛАТЬ
  // !!!
  if (strs) {
    if (typeof strs === 'object') {
      for (const s of strs) {
        console.log(s)
      }
    } else if (typeof strs === 'string') {
      console.log(strs)
    }
  }
}

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

Напоследок, рассмотрим пример использования логического оператора "НЕ":

function multiplyAll(
  values: number[] | undefined,
  factor: number
): number[] | undefined {
  if (!values) {
    return values
  } else {
    return values.map((x) => x * factor)
  }
}

Проверка на равенство (equality narrowing)

Для сужения типов также можно воспользоваться инструкцией switch или операторами равенства ===, !==, ==, !=, например:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // Теперь мы можем вызывать любой строковый метод
    x.toUpperCase()
    // (method) String.toUpperCase(): string
    y.toLowerCase()
    // (method) String.toLowerCase(): string
  } else {
    console.log(x)
    // (parameter) x: string | number
    console.log(y)
    // (parameter) y: string | boolean
  }
}

Когда мы сравниваем значения x и y, TS знает, что их типы также должны быть равны. Поскольку string - это единственный общий тип, которым обладают и x, и y, TS знает, что x и y должны быть string в первой ветке.

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

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === 'object') {
      for (const s of strs) {
        // (parameter) strs: string[]
        console.log(s)
      }
    } else if (typeof strs === 'string') {
      console.log(strs)
      // (parameter) strs: string
    }
  }
}

Операторы абстрактного равенства (== и !=) также могут использоваться для сужения типов, в некоторых случаях их использование даже более эффективно, чем использование операторов строгого равенства (=== и !==). Например, выражение == null проверяет на равенство не только с null, но и с undefined. Аналогично выражение == undefined проверяет на равенство не только с undefined, но и с null.

interface Container {
  value: number | null | undefined
}

function multiplyValue(container: Container, factor: number) {
  // Удаляем 'null' и 'undefined' из типа
  if (container.value != null) {
    console.log(container.value)
    // (property) Container.value: number

    // Теперь мы можем безопасно умножать 'container.value'
    container.value *= factor
  }
}

Сужение типов с помощью оператора in

В JS существует оператор для определения наличия указанного свойства в объекте - оператор in. TS позволяет использовать данный оператор для сужения потенциальных типов.

Например, в выражении 'value' in x, где 'value' - строка, а x - объединение, истинная ветка сужает типы x к типам, которые имеют опциональное или обязательное свойство value, а ложная ветка сужает типы к типам, которые имеют опциональное или не имеют названного свойства:

type Fish = { swim: () => void }
type Bird = { fly: () => void }

function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    return animal.swim()
  }

  return animal.fly()
}

Наличие опциональных свойств в обоих ветках не является ошибкой, поскольку человек, например, может и плавать (swim), и летать (fly) (при наличии соответствующего снаряжения):

type Fish = { swim: () => void }
type Bird = { fly: () => void }
type Human = { swim?: () => void; fly?: () => void }

function move(animal: Fish | Bird | Human) {
  if ('swim' in animal) {
    animal
    // (parameter) animal: Fish | Human
  } else {
    animal
    // (parameter) animal: Bird | Human
  }
}

Сужение типов с помощью оператора instanceof

Оператор instanceof используется для определения того, является ли одна сущность "экземпляром" другой. Например, выражение x instanceof Foo проверяет, содержится ли Foo.prototype в цепочке прототипов x. Данный оператор применяется к значениям, сконструированным с помощью ключевого слова new. Он также может использоваться для сужения типов:

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString())
    // (parameter) x: Date
  } else {
    console.log(x.toUpperCase())
    // (parameter) x: string
  }
}

Присвоения (assignments)

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

let x = Math.random() < 0.5 ? 10 : 'hello world!'
// let x: string | number

x = 1

console.log(x)
// let x: number

x = 'goodbye!'

console.log(x)
// let x: string

Данные присвоения являются валидными, поскольку типом, определенным для x, является string | number. Однако, если мы попытаемся присвоить x логическое значение, то получим ошибку:

x = true
// Type 'boolean' is not assignable to type 'string | number'.

console.log(x)
// let x: string | number

Анализ потока управления

Анализ потока управления (control flow analysis) - это анализ, выполняемый TS на основе достижимости кода (reachability) и используемый им для сужения типов с учетом защитников типа и присвоений. При анализе переменной поток управления может разделяться и объединяться снова и снова, поэтому переменная может иметь разные типы в разных участках кода:

function example() {
  let x: string | number | boolean

  x = Math.random() < 0.5

  console.log(x)
  // let x: boolean

  if (Math.random() < 0.5) {
    x = 'hello'
    console.log(x)
    // let x: string
  } else {
    x = 100
    console.log(x)
    // let x: number
  }

  return x
  // let x: string | number
}

Использование предикатов типа (type predicates)

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

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined
}

pet is Fish - это наш предикат. Предикат имеет форму parameterName is Type, где parameterName - это название параметра из сигнатуры текущей функции.

При вызове isFish с любой переменной, TS "сузит" эту переменную до указанного типа, разумеется, при условии, что оригинальный тип совместим с указанным.

const pet = getSmallPet()

if (isFish(pet)) {
  pet.swim()
} else {
  pet.fly()
}

:::note Обратите внимание

TS знает не только то, что pet - это Fish в ветке if, но также то, что в ветке else pet - это Bird.

:::

Мы можем использовать защитника isFish для фильтрации массива Fish | Bird и получения массива Fish:

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()]
const underWater1: Fish[] = zoo.filter(isFish)
// или
const underWater2: Fish[] = zoo.filter(isFish) as Fish[]

// В более сложных случаях, может потребоваться повторное использование предиката
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === 'sharkey') return false
  return isFish(pet)
})

Исключающие объединения (discriminated unions)

Предположим, что мы пытаемся закодировать фигуры, такие как круги и квадраты. Круги "следят" за радиусом, а квадраты - за длиной стороны. Для обозначения того, с какой фигурой мы имеем дело, будет использоваться свойство kind. Вот наша первая попытка определить Shape:

interface Shape {
  kind: 'circle' | 'square'
  radius?: number
  sideLength: number
}

Использование 'circle' | 'square' вместо string позволяет избежать орфографических ошибок:

function handleShape(shape: Shape) {
  // Упс!
  if (shape.kind === 'rect') {
    // This condition will always return 'false' since the types '"circle" | "square"' and '"rect"' have no overlap.
    // Данное условие всегда возвращает `false`, поскольку типы '"circle" | "square"' и '"rect"' не пересекаются
    // ...
  }
}

Давайте создадим функцию getArea для вычисления площади фигур. Начнем с кругов:

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2
  // Object is possibly 'undefined'.
  // Потенциальным значением объекта является 'undefined'
}

С включенной настройкой strictNullChecks мы получаем ошибку, поскольку radius может быть не определен. Что если перед выполнением кода проверить свойство kind?

function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius ** 2
    // Object is possibly 'undefined'.
  }
}

Хм, TS по-прежнему не понимает, что с этим делать. В данном случае, мы знаем больше, чем компилятор. Можно попробовать использовать ненулевое утверждение (! после shape.radius), чтобы сообщать компилятору о том, что radius точно присутствует в типе:

function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius! ** 2
  }
}

Код компилируется без ошибок, но решение не выглядит идеальным. Мы, определенно, можем сделать его лучше. Проблема состоит в том, что компилятор не может определить, имеется ли свойство radius или sideLength на основе свойства kind. Перепишем определение Shape:

interface Circle {
  kind: 'circle'
  radius: number
}

interface Square {
  kind: 'square'
  sideLength: number
}

type Shape = Circle | Square

Мы разделили Shape на два разных типа с разными значениями свойства kind, свойства radius и sideLength являются обязательными в соответствующих типах.

Посмотрим, что произойдет при попытке получить доступ к свойству radius типа Shape:

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2
  // Property 'radius' does not exist on type 'Shape'. Property 'radius' does not exist on type 'Square'.
}

Мы получаем ошибку. На этот раз TS сообщает нам о том, что shape может быть Square, у которого нет radius. Что если мы снова попытаемся выполнить проверку свойства kind?

function getArea(shape: Shape) {
  if (shape.kind === 'circle') {
    return Math.PI * shape.radius ** 2
    // (parameter) shape: Circle
  }
}

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

В нашем случае, общим свойством является kind (которое рассматривается как особое свойство Shape). Проверка значения этого свойства позволяет сужать shape до определенного типа. Другими словами, если значением kind является 'circle', shape сужается до Circle.

Тоже самое справедливо и в отношении инструкции switch. Теперь мы можем реализовать нашу функцию getArea без !:

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    // (parameter) shape: Circle
    case 'square':
      return shape.sideLength ** 2
    // (parameter) shape: Square
  }
}

Тип never

Для представления состояния, которого не должно существовать, в TS используется тип never.

Исчерпывающие проверки (exhaustiveness checking)

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

Например, добавим такой default в getArea:

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'square':
      return shape.sideLength ** 2
    default:
      const _exhaustiveCheck: never = shape
      return _exhaustiveCheck
  }
}

После этого попытка добавления нового члена в объединение Shape будет приводить к ошибке:

interface Triangle {
  kind: 'triangle'
  sideLength: number
}

type Shape = Circle | Square | Triangle

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'square':
      return shape.sideLength ** 2
    default:
      const _exhaustiveCheck: never = shape
      // Type 'Triangle' is not assignable to type 'never'.
      return _exhaustiveCheck
  }
}

Подробнее о функциях

Функции - это основные строительные блоки любого приложения, будь то функции, импортируемые из другого модуля, или методы класса. В TS существует несколько способов описания того, как функции вызываются.

Тип функции в форме выражения (function type expressions)

Простейшим способом описания типа функции является выражение. Такие типы похожи на стрелочные функции:

function greeter(fn: (a: string) => void) {
  fn('Hello, World')
}

function printToConsole(s: string) {
  console.log(s)
}

greeter(printToConsole)

Выражение (a: string) => void означает "функция с одним параметром a типа string, которая ничего не возвращает". Как и в случае с определением функции, если тип параметра не указан, он будет иметь значение any.

:::note Обратите внимание

Название параметра является обязательным. Тип функции (string) => void означает "функция с параметром string типа any"!

:::

Разумеется, для типа функции можно использовать синоним:

type GreetFn = (a: string) => void
function greeter(fn: GreetFn) {
  // ...
}

Сигнатуры вызова (call signatures)

В JS функции, кроме того, что являются вызываемыми (callable), могут иметь свойства. Однако, тип-выражение не позволяет определять свойства функции. Для описания вызываемой сущности (entity), обладающей некоторыми свойствами, можно использовать сигнатуру вызова (call signature) в объектном типе:

type DescFn = {
  description: string
  (someArg: number): boolean
}
function doSomething(fn: DescFn) {
  console.log(`Значением, возвращаемым ${fn.description} является ${fn(6)}`)
}

:::note Обратите внимание

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

:::

Сигнатуры конструктора (construct signatures)

Как известно, функции могут вызываться с ключевым словом new. TS считает такие функции конструкторами, поскольку они, как правило, используются для создания объектов. Для определения типов таких функций используется сигнатура конструктора:

type SomeConstructor = {
  new (s: string): SomeObject
}
function fn(ctor: SomeConstructor) {
  return new ctor('Hello!')
}

Некоторые объекты, такие, например, как объект Date, могут вызываться как с, так и без new. Сигнатуры вызова и конструктора можно использовать совместно:

interface CallOrConstruct {
  new (s: string): Date
  (n?: number): number
}

Общие функции или функции-дженерики (generic functions)

Часто тип данных, возвращаемых функцией, зависит от типа передаваемого функции аргумента или же два типа возвращаемых функцией значений зависят друг от друга. Рассмотрим функцию, возвращающую первый элемент массива:

function firstElement(arr: any[]) {
  return arr[0]
}

Функция делают свою работу, но, к сожалению, типом возвращаемого значения является any. Было бы лучше, если бы функция возвращала тип элемента массива.

В TS общие типы или дженерики (generics) используются для описания связи между двумя значениями. Это делается с помощью определения параметра Type в сигнатуре функции:

function firstElement<Type>(arr: Type[]): Type {
  return arr[0]
}

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

// `s` имеет тип `string`
const s = firstElement(['a', 'b', 'c'])
// `n` имеет тип `number`
const n = firstElement([1, 2, 3])

Предположение типа (inference)

Мы можем использовать несколько параметров типа. Например, самописная версия функции map может выглядеть так:

function map<Input, Output>(
  arr: Input[],
  func: (arg: Input) => Output
): Output[] {
  return arr.map(func)
}

// Типом `n` является `string`,
// а типом `parsed` - `number[]`
const parsed = map(['1', '2', '3'], (n) => parseInt(n))

:::note Обратите внимание

В приведенном примере TS может сделать вывод относительно типа Input на основе переданного string[], а относительно типа Output на основе возвращаемого number.

:::

Ограничения (constraints)

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

Реализуем функцию, возвращающую самое длинное из двух значений. Для этого нам потребуется свойство length, которое будет числом. Мы ограничим параметр типа типом number с помощью ключевого слова extends:

function longest<Type extends { length: number }>(a: Type, b: Type) {
  if (a.length >= b.length) {
    return a
  } else {
    return b
  }
}

// Типом `longerArr` является `number[]`
const longerArr = longest([1, 2], [1, 2, 3])
// Типом `longerStr` является `string`
const longerStr = longest('alice', 'bob')
// Ошибка! У чисел нет свойства `length`
const notOK = longest(10, 100)
// Argument of type 'number' is not assignable to parameter of type '{ length: number }'.
// Аргумент типа 'number' не может быть присвоен параметру типа '{ length: number; }'

Мы позволяем TS предполагать тип значения, возвращаемого из функции longest.

Поскольку мы свели Type к { length: number }, то получили доступ к свойству length параметров a и b. Без ограничения типа у нас бы не было такого доступа, потому что значения этих свойств могли бы иметь другой тип - без длины.

Типы longerArr и longerStr были выведены на основе аргументов. Запомните, дженерики определяют связь между двумя и более значениями одного типа!

Наконец, как мы и ожидали, вызов longest(10, 100) отклоняется, поскольку тип number не имеет свойства length.

Работа с ограниченными значениями

Вот пример распространенной ошибки, возникающей при работе с ограничениями дженериков:

function minLength<Type extends { length: number }>(
  obj: Type,
  min: number
): Type {
  if (obj.length >= min) {
    return obj
  } else {
    return { length: min }
  }
}
// Type '{ length: number; }' is not assignable to type 'Type'. '{ length: number; }' is assignable to the constraint of type 'Type', but 'Type' could be instantiated with a different subtype of constraint '{ length: number; }'.
// Тип '{ length: number; }' не может быть присвоен типу 'Type'. '{ length: number; }' может присваиваться ограничению типа 'Type', но 'Type' может быть инстанцирован с другим подтипом ограничения '{ length: number; }'

На первый взгляд может показаться, что все в порядке - Type сведен к { length: number }, и функция возвращает либо Type, либо значение, совпадающее с ограничением. Проблема состоит в том, что функция может вернуть объект, идентичный тому, который ей передается, а не просто объект, совпадающий с ограничением. Если бы во время компиляции не возникло ошибки, мы могли бы написать что-то вроде этого:

// `arr` получает значение `{ length: 6 }`
const arr = minLength([1, 2, 3], 6)
// и ломает приложение, поскольку массивы
// имеют метод `slice`, но не возвращаемый объект!
console.log(arr.slice(0))

Определение типа аргументов

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

function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
  return arr1.concat(arr2)
}

При обычном вызове данной функции с несовпадающими по типу массивами возникает ошибка:

const arr = combine([1, 2, 3], ['привет'])
// Type 'string' is not assignable to type 'number'.

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

const arr = combine<string | number>([1, 2, 3], ['привет'])

Руководство по написанию хороших функций-дженериков

Используйте параметры типа без ограничений

Рассмотрим две похожие функции:

function firstElement1<Type>(arr: Type[]) {
  return arr[0]
}

function firstElement2<Type extends any[]>(arr: Type) {
  return arr[0]
}

// a: number (хорошо)
const a = fisrtElement1([1, 2, 3])
// b: any (плохо)
const b = fisrtElement2([1, 2, 3])

Предполагаемым типом значения, возвращаемого функцией firstElement1 является Type, а значения, возвращаемого функцией firstElement2 - any. Это объясняется тем, что TS разрешает (resolve) выражение arr[0] с помощью ограничения типа вместо того, чтобы ждать разрешения элемента после вызова функции.

Правило: по-возможности, используйте параметры типа без ограничений.

Используйте минимальное количество параметров типа

Вот еще одна парочка похожих функций:

function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
  return arr.filter(func)
}

function filter2<Type, Func extends (arg: Type) => boolean>(
  arr: Type[],
  func: Func
): Type[] {
  return arr.filter(func)
}

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

Правило: всегда используйте минимальное количество параметров типа.

Параметры типа должны указываться дважды

Иногда мы забываем, что функция не обязательно должна быть дженериком:

function greet<Str extends string>(s: Str) {
  console.log(`Привет, ${s}!`)
}

greet('народ')

Вот упрощенная версия данной функции:

function greet(s: string) {
  console.log(`Привет, ${s}!`)
}

Запомните, параметры типа предназначены для связывания типов нескольких значений.

Правило: если параметр типа появляется в сигнатуре функции только один раз, то, скорее всего, он вам не нужен.

Опциональные параметры (optional parameters)

Функции в JS могут принимать произвольное количество аргументов. Например, метод toFixed принимает опциональное количество цифр после запятой:

function fn(n: number) {
  console.log(n.toFixed()) // 0 аргументов
  console.log(n.toFixed(3)) // 1 аргумент
}

Мы можем смоделировать это в TS, пометив параметр как опциональный с помощью ?:

function f(x?: number) {
  // ...
}
f() // OK
f(10) // OK

Несмотря на то, что тип параметра определен как number, параметр x на самом деле имеет тип number | undefined, поскольку неопределенные параметры в JS получают значение undefined.

Мы также можем указать "дефолтный" параметр (параметр по умолчанию):

function f(x = 10) {
  // ...
}

Теперь в теле функции f параметр x будет иметь тип number, поскольку любой аргумент со значением undefined будет заменен на 10. Обратите внимание: явная передача undefined означает "отсутствующий" аргумент.

declare function f(x?: number): void
// OK
f()
f(10)
f(undefined)

Опциональные параметры в функциях обратного вызова

При написании функций, вызывающих "колбэки", легко допустить такую ошибку:

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i)
  }
}

Указав index?, мы хотим, чтобы оба этих вызова были легальными:

myForEach([1, 2, 3], (a) => console.log(a))
myForEach([1, 2, 3], (a, i) => console.log(a, i))

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

function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i])
  }
}

Поэтому попытка вызова такой функции приводит к ошибке:

myForEach([1, 2, 3], (a, i) => {
  console.log(i.toFixed())
  // Object is possibly 'undefined'.
  // Возможным значением объекта является 'undefined'
})

В JS при вызове функции с большим (ударение на первый слог) количеством аргументов, чем указано в определении функции, дополнительные параметры просто игнорируются. TS ведет себя аналогичным образом. Функции с меньшим количеством параметров (одного типа) могут заменять функции с большим количеством параметров.

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

Перегрузка функции (function overload)

Некоторые функции могут вызываться с разным количеством аргументов. Например, мы можем написать функцию, возвращающую Date, которая принимает время в мс (timestamp, один аргумент) или день/месяц/год (три аргумента).

В TS такую функцию можно реализовать с помощью сигнатур перегрузки (overload signatures). Для этого перед телом функции указывается несколько ее сигнатур:

function makeDate(timestamp: number): Date
function makeDate(d: number, m: number, y: number): Date
function makeDate(dOrTimestamp: number, m?: number, y?: number): Date {
  if (m !== undefined && y !== undefined) {
    return new Date(y, m, dOrTimestamp)
  } else {
    return new Date(dOrTimestamp)
  }
}
const d1 = makeDate(12345678)
const d2 = makeDate(5, 5, 5)
const d3 = makeDate(1, 3)
// No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.
// Нет перегрузки, принимающей 2 аргумента, но существуют перегрузки, ожидающие получения 1 или 3 аргумента

В приведенном примере мы реализовали две перегрузки: одну, принимающую один аргумент, и вторую, принимающую три аргумента. Первые две сигнатуры называются сигнатурами перегрузки.

Затем мы реализовали функцию с совместимой сигнатурой (compatible signature). Функции имеют сигнатуру реализации (implementation signature), но эта сигнатура не может вызываться напрямую. Несмотря на то, что мы написали функцию с двумя опциональными параметрами после обязательного, она не может вызываться с двумя параметрами!

Сигнатуры перегрузки и сигнатура реализации

Предположим, что у нас имеется такой код:

function fn(x: string): void
function fn() {
  // ...
}
// Мы ожидаем, что функция может вызываться без аргументов
fn()
// Expected 1 arguments, but got 0.
// Ожидалось получение 1 аргумента, а получено 0

Почему в данном случае возникает ошибка? Дело в том, что сигнатура реализации не видна снаружи (за пределами тела функции). Поэтому при написании перегруженной функции всегда нужно указывать две или более сигнатуры перегрузки перед сигнатурой реализации.

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

function fn(x: boolean): void
// Неправильный тип аргумента
function fn(x: string): void
// This overload signature is not compatible with its implementation signature.
// Данная сигнатура перегрузки не совместима с сигнатурой ее реализации
function(x: boolean) {}
function fn(x: string): string
// Неправильный тип возвращаемого значения
function(x: number): boolean
// This overload signature is not compatible with its implementation signature.
function fn(x: string | number) {
  return 'упс'
}

Правила реализации хороших перегрузок функции

Рассмотрим функцию, возвращающую длину строки или массива:

function len(s: string): number
function len(arr: any[]): number
function len(x: any) {
  return x.length
}

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

len('') // OK
len([0]) // OK
len(Math.random() > 0.5 ? 'привет' : [0])
/*
No overload matches this call.
  Overload 1 of 2, '(s: string): number', gave the following error.
    Argument of type 'number[] | "привет"' is not assignable to parameter of type 'string'.
      Type 'number[]' is not assignable to type 'string'.
  Overload 2 of 2, '(arr: any[]): number', gave the following error.
    Argument of type 'number[] | "привет"' is not assignable to parameter of type 'any[]'.
      Type 'string' is not assignable to type 'any[]'.
*/
/*
Ни одна из перегрузок не совпадает с вызовом.
  Перегрузка 1 из 2, '(s: string): number', возвращает следующую ошибку.
    Аргумент типа 'number[] | "привет"' не может быть присвоен параметру типа 'string'.
      Тип 'number[]' не может быть присвоен типу 'string'.
  Перегрузка 2 из 2, '(arr: any[]): number', возвращает следующую ошибку.
    Аргумент типа 'number[] | "привет"' не может быть присвоен типу 'any[]'.
      Тип 'string' не может быть присвоен типу 'any[]'.
*/

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

function len(x: any[] | string) {
  return x.length
}

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

Правило: по-возможности используйте объединения вместо перегрузок функции.

Определение this в функциях

Рассмотрим пример:

const user = {
  id: 123,

  admin: false,
  becomeAdmin: function () {
    this.admin = true
  }
}

TS "понимает", что значением this функции user.becomeAdmin является внешний объект user. В большинстве случаев этого достаточно, но порой нам требуется больше контроля над тем, что представляет собой this. Спецификация JS определяет, что мы не можем использовать this в качестве названия параметра. TS использует это синтаксическое пространство (syntax space), позволяя определять тип this в теле функции:

const db = getDB()
const admins = db.filterUsers(function () {
  return this.admin
})

:::note Обратите внимание

В данном случае мы не можем использовать стрелочную функцию.

:::

const db = getDB()
const admins = db.filterUsers(() => this.admin)
// The containing arrow function captures the global value of 'this'. Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.
// Стрелочная функция перехватывает глобальное значение 'this'. Неявным типом элемента является 'any', поскольку тип 'typeof globalThis' не имеет сигнатуры индекса

Другие типы, о которых следует знать

void

void представляет значение, возвращаемое функцией, которая ничего не возвращает. Если в теле функции отсутствует оператор return или после этого оператора не указано возвращаемого значения, предполагаемым типом возвращаемого такой функцией значения будет void:

// Предполагаемым типом является `void`
function noop() {
  return
}

В JS функция, которая ничего не возвращает, "неявно" возвращает undefined. Однако, в TS void и undefined - это разные вещи.

:::note Обратите внимание

void - это не тоже самое, что undefined.

:::

object

Специальный тип object представляет значение, которое не является примитивом (string, number, boolean, symbol, null, undefined). object отличается от типа пустого объекта ({}), а также от глобального типа Object. Скорее всего, вам никогда не потребуется использовать Object.

Правило: object - это не Object. Всегда используйте object!

:::note Обратите внимание

В JS функции - это объекты: они имеют свойства, Object.prototype в цепочке прототипов, являются instanceof Object, мы можем вызывать на них Object.keys и т.д. По этой причине в TS типом функций является object.

:::

unknown

Тип unknown представляет любое значение. Он похож на тип any, но является более безопасным, поскольку не позволяет ничего делать с неизвестным значением:

function f1(a: any) {
  a.b() // OK
}
function f2(a: unknown) {
  a.b()
  // Object is of type 'unknown'.
  // Типом объекта является 'unknown'
}

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

function safeParse(s: string): unknown {
  return JSON.parse(s)
}

const obj = safeParse(someRandomString)

never

Некоторые функции никогда не возвращают значений:

function fail(msg: string): never {
  throw new Error(msg)
}

Тип never представляет значение, которого не существует. Чаще всего, это означает, что функция выбрасывает исключение или останавливает выполнение программы.

never также появляется, когда TS определяет, что в объединении больше ничего не осталось:

function fn(x: string | number) {
  if (typeof x === 'string') {
    // ...
  } else if (typeof x === 'number') {
    // ...
  } else {
    x // типом `x` является `never`!
  }
}

Function

Глобальный тип Function описывает такие свойства как bind, call, apply и другие, характерные для функций в JS. Он также имеет специальное свойство, позволяющее вызывать значения типа Function - такие вызовы возвращают any:

function doSomething(f: Function) {
  f(1, 2, 3)
}

Такой вызов функции называется нетипизированным и его лучше избегать из-за небезопасного возвращаемого типа any.

Если имеется необходимость принимать произвольную функцию без ее последующего вызова, лучше предпочесть более безопасный тип () => void.

Оставшиеся параметры и аргументы

Оставшиеся параметры (rest parameters)

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

Оставшиеся параметры указываются после других параметров с помощью ...:

function multiply(n: number, ...m: number[]) {
  return m.map((x) => n * x)
}
// `a` получает значение [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4)

В TS неявным типом таких параметров является any[], а не any. Любая аннотация типа для них должна иметь вид Array<T> или T[], или являться кортежем.

Оставшиеся аргументы (rest arguments)

Синтаксис распространения (синонимы: расширение, распаковка) (spread syntax) позволяет передавать произвольное количество элементов массива. Например, метод массива push принимает любое количество аргументов:

const arr1 = [1, 2, 3]
const arr2 = [4, 5, 6]
arr1.push(...arr2)

:::note Обратите внимание

TS не считает массивы иммутабельными. Это может привести к неожиданному поведению.

:::

// Предполагаемым типом `args` является `number[]` - массив с 0 или более чисел
// а не конкретно с 2 числами
const args = [8, 5]
const angle = Math.atan2(...args)
// Expected 2 arguments, but got 0 or more.
// Ожидалось получение 2 аргументов, а получено 0 или более

Самым простым решением данной проблемы является использование const:

// Предполагаемым типом является кортеж, состоящий из 2 элементов
const args = [8, 5] as const
// OK
const angle = Math.atan2(...args)

Деструктуризация параметров (parameter destructuring)

Деструктуризация параметров используется для распаковки объекта, переданного в качестве аргумента, в одну или более локальную переменную в теле функции. В `JS` это выглядит так:
function sum({ a, b, c }) {
  console.log(a + b + c)
}
sum({ a: 10, b: 3, c: 9 })

Аннотация типа для объекта указывается после деструктуризации:

function sum({ a, b, c }: { a: number; b: number; c: number }) {
  console.log(a + b + c)
}

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

type ABC = { a: number; b: number; c: number }
function sum({ a, b, c }: ABC) {
  console.log(a + b + c)
}

Возможность присвоения функций переменным

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

Контекстуальная типизация (contextual typing), основанная на void, не запрещает функции что-либо возвращать. Другими словами, функция, типом возвращаемого значения которой является void - type vf = () => void, может возвращать любое значение, но это значение будет игнорироваться.

Все приведенные ниже реализации типа () => void являются валидными:

type voidFn = () => void

const f1: voidFn = () => {
  return true
}

const f2: voidFn = () => true

const f3: voidFn = function () {
  return true
}

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

const v1 = f1()

const v2 = f2()

const v3 = f3()

Поэтому следующий код является валидным, несмотря на то, что Array.prototype.push возвращает число, а Array.prototype.forEach ожидает получить функцию с типом возвращаемого значения void:

const src = [1, 2, 3]
const dst = [0]

src.forEach((el) => dist.push(el))

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

function f2(): void {
  // Ошибка
  return true
}

const f3 = function (): void {
  // Ошибка
  return true
}

Объектные типы

В JS обычным способом группировки и передачи данных являются объекты. В TS они представлены объектными типами (object types).

Как мы видели ранее, они могут быть анонимными:

function greet(person: { name: string; age: number }) {
  return `Привет, ${person.name}!`
}

или именоваться с помощью интерфейсов (interfaces):

interface Person {
  name: string
  age: number
}

function greet(person: Person) {
  return `Привет, ${person.name}!`
}

или синонимов типа (type aliases):

type Person {
  name: string
  age: number
}

function greet(person: Person) {
  return `Привет, ${person.name}!`
}

Во всех приведенных примерах наша функция принимает объект, который содержит свойство name (значение которого должно быть типа string) и age (значение которого должно быть типа number).

Модификаторы свойств (property modifiers)

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

Опциональные свойства (optional properties)

Свойства могут быть помечены как опциональные (необязательные) путем добавления вопросительного знака (?) после их названий:

interface PaintOptions {
  shape: Shape
  xPos?: number
  yPos?: number
}

function paintShape(opts: PaintOptions) {
  // ...
}

const shape = getShape()
paintShape({ shape })
paintShape({ shape, xPos: 100 })
paintShape({ shape, yPos: 100 })
paintShape({ shape, xPos: 100, yPos: 100 })

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

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

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos
  // (property) PaintOptions.xPos?: number | undefined
  let yPos = opts.yPos
  // (property) PaintOptions.yPos?: number | undefined
  // ...
}

В JS при доступе к несуществующему свойству возвращается undefined. Добавим обработку этого значения:

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos === undefined ? 0 : opts.xPos
  // let xPos: number
  let yPos = opts.yPos === undefined ? 0 : opts.yPos
  // let yPos: number
  // ...
}

Теперь все в порядке. Но для определения "дефолтных" значений (значений по умолчанию) параметров в JS существует специальный синтаксис:

function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
  console.log('x coordinate at', xPos)
  // var xPos: number
  console.log('y coordinate at', yPos)
  // var yPos: number
  // ...
}

В данном случае мы деструктурировали параметр painShape и указали значения по умолчанию для xPos и yPos. Теперь они присутствуют в теле функции painShape, но являются опциональными при ее вызове.

:::note Обратите внимание

В настоящее время не существует способа поместить аннотацию типа в деструктуризацию, поскольку такой синтаксис будет интерпретирован JS иначе.

:::

function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape)
  // Cannot find name 'shape'. Did you mean 'Shape'?
  // Невозможно найти 'shape'. Возможно, вы имели ввиду 'Shape'
  render(xPos)
  // Cannot find name 'xPos'.
  // Невозможно найти 'xPos'
}

shape: Shape означает "возьми значение свойства shape и присвой его локальной переменной Shape". Аналогично xPos: number создает переменную number, значение которой основано на параметре xPos.

Свойства, доступные только для чтения (readonly properties)

Свойства могут быть помечены как доступные только для чтения с помощью ключевого слова readonly. Такие свойства не могут перезаписываться в процессе проверки типов:

interface SomeType {
  readonly prop: string
}

function doSomething(obj: SomeType) {
  // Мы может читать (извлекать значения) из 'obj.prop'.
  console.log(`prop has the value '${obj.prop}'.`)

  // Но не можем изменять значение данного свойства
  obj.prop = 'hello'
  // Cannot assign to 'prop' because it is a read-only property.
  // Невозможно присвоить значение 'prop', поскольку оно является доступным только для чтения
}

Использование модификатора readonly не делает саму переменную иммутабельной (неизменяемой), это лишь запрещает присваивать ей другие значения:

interface Home {
  readonly resident: { name: string; age: number }
}

function visitForBirthday(home: Home) {
  // Мы можем читать и обновлять свойства 'home.resident'.
  console.log(`С Днем рождения, ${home.resident.name}!`)
  home.resident.age++
}

function evict(home: Home) {
  // Но мы не можем изменять значение свойства 'resident'
  home.resident = {
    // Cannot assign to 'resident' because it is a read-only property.
    name: 'Victor the Evictor',
    age: 42
  }
}

readonly сообщает TS, как должны использоваться объекты. При определении совместимости двух типов TS не проверяет, являются ли какие-либо свойства доступными только для чтения. Поэтому такие свойства можно изменять с помощью синонимов:

interface Person {
  name: string
  age: number
}

interface ReadonlyPerson {
  readonly name: string
  readonly age: number
}

let writablePerson: Person = {
  name: 'John Smith',
  age: 42
}

// работает
let readonlyPerson: ReadonlyPerson = writablePerson

console.log(readonlyPerson.age) // 42
writablePerson.age++
console.log(readonlyPerson.age) // 43

Сигнатуры индекса (index signatures)

Иногда мы не знаем названий всех свойств типа, но знаем форму значений.

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

interface StringArray {
  [index: number]: string
}

const myArray: StringArray = getStringArray()
const secondItem = myArray[1]
// const secondItem: string

В приведенном примере у нас имеется интерфейс StringArray, содержащий сигнатуру индекса. Данная сигнатура указывает на то, что при индексации StringArray с помощью number возвращается string.

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

Несмотря на поддержку обоих типов индексаторов (indexers), тип, возвращаемый из числового индексатора, должен быть подтипом типа, возвращаемого строковым индексатором. Это объясняется тем, что при индексации с помощью number, JS преобразует его в string перед индексацией объекта. Это означает, что индексация с помощью 100 (number) эквивалента индексации с помощью "100" (string), поэтому они должны быть согласованными между собой.

interface Animal {
  name: string
}

interface Dog extends Animal {
  breed: string
}

// Ошибка: индексация с помощью числовой строки может привести к созданию другого типа Animal!
interface NotOkay {
  [x: number]: Animal
  // Numeric index type 'Animal' is not assignable to string index type 'Dog'.
  // Числовой индекс типа 'Animal' не может быть присвоен строковому индексу типа 'Dog'
  [x: string]: Dog
}

В то время, как сигнатуры строкового индекса являются хорошим способом для описания паттерна "словарь", они предопределяют совпадение всех свойств их возвращаемым типам. Это объясняется тем, что строковый индекс определяет возможность доступа к obj.property с помощью obj['property']. В следующем примере тип name не совпадает с типом строкового индекса, поэтому во время проверки возникает ошибка:

interface NumberDictionary {
  [index: string]: number

  length: number // ok
  name: string
  // Property 'name' of type 'string' is not assignable to string index type 'number'.
}

Тем не менее, свойства с разными типами являются валидными в случае, когда сигнатура индекса - это объединение типов (union):

interface NumberOrStringDictionary {
  [index: string]: number | string
  length: number // ok, `length` - это число
  name: string // ok, `name` - это строка
}

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

interface ReadonlyStringArray {
  readonly [index: number]: string
}

let myArray: ReadonlyStringArray = getReadOnlyStringArray()
myArray[2] = 'John'
// Index signature in type 'ReadonlyStringArray' only permits reading.
// Сигнатура индекса в типе 'ReadonlyStringArray' допускает только чтение

Расширение типов (extending types)

Что если мы хотим определить тип, который является более конкретной версией другого типа? Например, у нас может быть тип BasicAddress, описывающий поля, необходимые для отправки писем и посылок в США:

interface BasicAddress {
  name?: string
  street: string
  city: string
  country: string
  postalCode: string
}

В некоторых случаях этого будет достаточно, однако адреса часто имеют литералы. Для таких случаев мы можем определить AddressWithUnit:

interface AddressWithUnit {
  name?: string
  unit: string
  street: string
  city: string
  country: string
  postalCode: string
}

Неужели не существует более простого способа добавления дополнительных полей? На самом деле, мы можем просто расширить BasicAddress, добавив к нему новые поля, которые являются уникальными для AddressWithUnit:

interface BasicAddress {
  name?: string
  street: string
  city: string
  country: string
  postalCode: string
}

interface AddressWithUnit extends BasicAddress {
  unit: string
}

Ключевое слово extends позволяет копировать членов именованных типов в другие типы. Оно также указывает на связь между типами.

Интерфейсы также могут расширяться с помощью нескольких типов одновременно:

interface Colorful {
  color: string
}

interface Circle {
  radius: number
}

interface ColorfulCircle extends Colorful, Circle {}

const cc: ColorfulCircle = {
  color: 'red',
  radius: 42
}

Пересечение типов (intersection types)

interface позволяет создавать новые типы на основе других посредством их расширения. TS также предоставляет другую конструкцию, которая называется пересечением типов или пересекающимися типами и позволяет комбинировать существующие объектные типы. Пересечение типов определяется с помощью оператора &:

interface Colorful {
  color: string
}

interface Circle {
  radius: number
}

type ColorfulCircle = Colorful & Circle

Пересечение типов Colorful и Circle приводит к возникновению типа, включающего все поля Colorful и Circle:

function draw(circle: Colorful & Circle) {
  console.log(`Цвет круга: ${circle.color}`)
  console.log(`Радиус круга: ${circle.radius}`)
}

// OK
draw({ color: 'blue', radius: 42 })

// опечатка
draw({ color: 'red', raidus: 42 })
/*
Argument of type '{ color: string, raidus: number }' is not assignable to parameter of type 'Colorful & Circle'.
  Object literal may only specify known properties, but 'raidus' does not exist in type 'Colorful & Circle'. Did you mean to write 'radius'?
*/
/*
Аргумент типа '{ color: string, raidus: number }' не может быть присвоен параметру с типом 'Colorful & Circle'.
  С помощью литерала объекта могут определяться только известные свойства, а свойства с названием 'raidus' не существует в типе 'Colorful & Circle'. Возможно, вы имели ввиду 'radius'
*/

Интерфейс или пересечение типов?

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

Общие объектные типы (generic object types)

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

interface Box {
  contents: any
}

Этот код работает, но тип any является небезопасным с точки зрения системы типов. Вместо него мы могли бы использовать unknown, но это будет означать необходимость выполнения предварительных проверок и подверженных ошибкам утверждений типов (type assertions).

interface Box {
  contents: unknown
}

let x: Box {
  contents: 'привет, народ'
}

// мы можем проверить `x.contents`
if (typeof x.contents === 'string') {
  console.log(x.contents.toLowerCase())
}

// или можем использовать утверждение типа
console.log((x.contents as string).toLowerCase())

Более безопасным способом будет определение различных типов Box для каждого типа contents:

interface NumberBox {
  contents: number
}

interface StringBox {
  contents: string
}

interface BooleanBox {
  contents: boolean
}

Однако, это обуславливает необходимость создания различных функций или перегрузок функции (function overloads) для работы с такими типами:

function setContents(box: StringBox, newContents: string): void
function setContents(box: NumberBox, newContents: number): void
function setContents(box: BooleanBox, newContents: boolean): void
function setContents(box: { contents: any }, newContents: any) {
  box.contents = newContents
}

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

Для решения данной проблемы мы можем создать общий (generic) тип Box, в котором объявляется параметр типа (type parameter):

interface Box<Type> {
  contents: Type
}

Затем, при ссылке на Box, мы должны определить аргумент типа (type argument) вместо Type:

let box: Box<string>

По сути, Box - это шаблон для настоящего типа, в котором Type будет заменен на конкретный тип. Когда TS видит Box<string>, он заменяет все вхождения Type в Box<Type> на string и заканчивает свою работу чем-то вроде { contents: string }. Другими словами, Box<string> работает также, как рассмотренный ранее StringBox.

interface Box<Type> {
  contents: Type
}
interface StringBox {
  contents: string
}

let boxA: Box<string> = { contents: 'привет' }
boxA.contents
// (property) Box<string>.contents: string

let boxB: StringBox = { contents: 'народ' }
boxB.contents
// (property) StringBox.contents: string

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

interface Box<Type> {
  contents: Type
}

interface Apple {
  // ....
}

// Тоже самое, что '{ contents: Apple }'.
type AppleBox = Box<Apple>

Это также означает, что нам не нужны перегрузки функции. Вместо них мы можем использовать общую функцию (generic function):

function setContents<Type>(box: Box<Type>, newContents: Type) {
  box.contents = newContents
}

Синонимы типов также могут быть общими. Вот как мы можем определить общий тип (generic type) Box:

type Box<Type> = {
  contents: Type
}

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

type OrNull<Type> = Type | null

type OneOrMany<Type> = Type | Type[]

type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>
// type OneOrManyOrNull<Type> = OneOrMany<Type> | null

type OneOrManyOrNullStrings = OneOrManyOrNull<string>
// type OneOrManyOrNullStrings = OneOrMany<string> | null

Тип Array

Синтаксис number[] или string[] - это сокращения для Array<number> и Array<string>, соответственно:

function doSomething(value: Array<string>) {
  // ...
}

let myArray: string[] = ['hello', 'world']

// оба варианта являются рабочими!
doSomething(myArray)
doSomething(new Array('hello', 'world'))

Array сам по себе является общим типом:

interface Array<Type> {
  /**
   *  Получает или устанавливает длину массива
   */
  length: number

  /**
   * Удаляет последний элемент массива и возвращает его
   */
  pop(): Type | undefined

  /**
   * Добавляет новые элементы в конец массива и возвращает новую длину массива
   */
  push(...items: Type[]): number

  // ...
}

Современный JS также предоставляет другие общие структуры данных, такие как Map<K, V>, Set<T> и Promise<T>. Указанные структуры могут работать с любым набором типов.

Тип ReadonlyArray

ReadonlyArray - это специальный тип, описывающий массив, который не должен изменяться.

function doStuff(values: ReadonlyArray<string>) {
  // Мы можем читать из `values`...
  const copy = values.slice()
  console.log(`Первым значением является ${values[0]}`)

  // но не можем их изменять
  values.push('Привет!')
  // Property 'push' does not exist on type 'readonly string[]'.
  // Свойства с названием 'push' не существует в типе 'readonly string[]'
}

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

В отличие от Array, ReadonlyArray не может использоваться как конструктор:

new ReadonlyArray('red', 'green', 'blue')
// 'ReadonlyArray' only refers to a type, but is being used as a value here.
// 'ReadonlyArray' всего лишь указывает на тип, поэтому не может использовать в качестве значения

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

const roArray: ReadonlyArray<string> = ['red', 'green', 'blue']

Для определения массива, доступного только для чтения, также существует сокращенный синтаксис, который выглядит как readonly Type[]:

function doStuff(values: readonly string[]) {
  // Мы можем читать из `values`...
  const copy = values.slice()
  console.log(`The first value is ${values[0]}`)

  // но не можем их изменять
  values.push('hello!')
  // Property 'push' does not exist on type 'readonly string[]'.
}

В отличие от модификатора свойств readonly, присваивание между Array и ReadonlyArray является однонаправленным (т.е. только обычный массив может быть присвоен доступному только для чтения массиву):

let x: readonly string[] = []
let y: string[] = []

x = y
y = x
// The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.
// Тип 'readonly string[]' является доступным только для чтения и не может быть присвоен изменяемому типу 'string[]'

Кортеж (tuple)

Кортеж - это еще одна разновидность типа Array с фиксированным количеством элементов определенных типов.

type StrNumPair = [string, number]

StrNumPair - это кортеж string и number. StrNumPair описывает массив, первый элемент которого (элемент под индексом 0) имеет тип string, а второй (элемент под индексом 1) - number.

function doSomething(pair: [string, number]) {
  const a = pair[0]
  // const a: string

  const b = pair[1]
  // const b: number
  // ...
}

doSomething(['hello', 42])

Если мы попытаемся получить элемент по индексу, превосходящему количество элементов, то получим ошибку:

function doSomething(pair: [string, number]) {
  // ...

  const c = pair[2]
  // Tuple type '[string, number]' of length '2' has no element at index '2'.
  // Кортеж '[string, number]' длиной в 2 элемента не имеет элемента под индексом '2'
}

Кортежи можно деструктурировать:

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash

  console.log(inputString)
  // const inputString: string

  console.log(hash)
  // const hash: number
}

Рассматриваемый кортеж является эквивалентом такой версии типа Array:

interface StringNumberPair {
  // Конкретные свойства
  length: 2
  0: string
  1: number

  // Другие поля 'Array<string | number>'
  slice(start?: number, end?: number): Array<string | number>
}

Элементы кортежа могут быть опциональными (?). Такие элементы указываются в самом конце и влияют на тип свойства length:

type Either2dOr3d = [number, number, number?]

function setCoords(coord: Either2dOr3d) {
  const [x, y, z] = coord
  // const z: number | undefined

  console.log(`
    Переданы координаты в ${coord.length} направлениях
  `)
  // (property) length: 2 | 3
}

Кортежи также могут содержать оставшиеся элементы (т.е. элементы, оставшиеся не использованными, rest elements), которые должны быть массивом или кортежем:

type StringNumberBooleans = [string, number, ...boolean[]]
type StringBooleansNumber = [string, ...boolean[], number]
type BooleansStringNumber = [...boolean[], string, number]

...boolean[] означает любое количество элементов типа boolean.

Такие кортежи не имеют определенной длины (length) - они имеют лишь набор известных элементов на конкретных позициях:

const a: StringNumberBooleans = ['hello', 1]
const b: StringNumberBooleans = ['beautiful', 2, true]
const c: StringNumberBooleans = ['world', 3, true, false, true, false, true]

Кортежи сами могут использоваться в качестве оставшихся параметров и аргументов. Например, такой код:

function readButtonInput(...args: [string, number, ...boolean[]]) {
  const [name, version, ...input] = args
  // ...
}

является эквивалентом следующего:

function readButtonInput(name: string, version: number, ...input: boolean[]) {
  // ...
}

Кортежи, доступные только для чтения (readonly tuple types)

Кортежи, доступные только для чтения, также определяются с помощью модификатора readonly:

function doSomething(pair: readonly [string, number]) {
  // ...
}

Попытка перезаписи элемента такого кортежа приведет к ошибке:

function doSomething(pair: readonly [string, number]) {
  pair[0] = 'Привет!'
  // Cannot assign to '0' because it is a read-only property.
}

Кортежи предназначены для определения типов иммутабельных массивов, так что хорошей практикой считается делать их доступными только для чтения. Следует отметить, что предполагаемым типом массива с утверждением const является readonly кортеж:

let point = [3, 4] as const

function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2)
}

distanceFromOrigin(point)
/*
Argument of type 'readonly [3, 4]' is not assignable to parameter of type '[number, number]'.
  The type 'readonly [3, 4]' is 'readonly' and cannot be assigned to the mutable type '[number, number]'.
*/

В приведенном примере distanceFromOrigin не изменяет элементы переданного массива, но ожидает получения изменяемого кортежа. Поскольку предполагаемым типом point является readonly [3, 4], он несовместим с [number, number], поскольку такой тип не может гарантировать иммутабельности элементов point.

Манипуляции с типами

Система типов TS позволяет создавать типы на основе других типов.

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

Дженерики

Создадим функцию identity, которая будет возвращать переданное ей значение:

function identity(arg: number): number {
  return arg
}

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

function identity(arg: any): any {
  return arg
}

Однако, при таком подходе мы не будем знать тип возвращаемого функцией значения.

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

function identity<Type>(arg: Type): Type {
  return arg
}

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

Такие функции называют общими (дженериками), поскольку они могут работать с любыми типами.

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

const output = identity<string>('myStr')
// let output: string

В данном случае принимаемым и возвращаемым типами является строка.

Второй способ заключается в делегировании типизации компилятору:

const output = identity('myStr')
// let output: string

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

Работа с переменными типа в дженериках

Что если мы захотим выводить в консоль длину аргумента arg перед его возвращением?

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length)
  // Property 'length' does not exist on type 'Type'.
  // Свойства 'length' не существует в типе 'Type'
  return arg
}

Мы получаем ошибку, поскольку переменные типа указывают на любой (а, значит, все) тип, следовательно, аргумент arg может не иметь свойства length, например, если мы передадим в функцию число.

Изменим сигнатуру функции таким образом, чтобы она работала с массивом Type:

function loggingIdentity<Type>(arg: Type[]): Type[] {
  console.log(arg.length)
  return arg
}

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

Мы можем сделать тоже самое с помощью такого синтаксиса:

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length)
  return arg
}

Общие типы

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

function identity<Type>(arg: Type): Type {
  return arg
}

const myIdentity: <Type>(arg: Type) => Type = identity

Мы можем использовать другое название для параметра общего типа:

function identity<Type>(arg: Type): Type {
  return arg
}

const myIdentity: <Input>(arg: Input) => Input = identity

Мы также можем создавать общие типы в виде сигнатуры вызова типа объектного литерала:

function identity<Type>(arg: Type): Type {
  return arg
}

const myIdentity: { <Type>(arg: Type): Type } = identity

Это приводит нас к общему интерфейсу:

interface GenericIdentityFn {
  <Type>(arg: Type): Type
}

function identity<Type>(arg: Type): Type {
  return arg
}

const myIdentity: GenericIdentityFn = identity

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

interface GenericIdentityFn<Type> {
  (arg: Type): Type
}

function identity<Type>(arg: Type): Type {
  return arg
}

const myIdentity: GenericIdentityFn<number> = identity

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

:::note Обратите внимание

Общие перечисления (enums) и пространства имен (namespaces) создавать нельзя.

:::

Общие классы

Общий класс имеет такую же форму, что и общий интерфейс:

class GenericNumber<NumType> {
  zeroValue: NumType
  add: (x: NumType, y: NumType) => NumType
}

const myGenericNum = new GenericNumber<number>()
myGenericNum.zeroValue = 0
myGenericNum.add = (x, y) => x + y

В случае с данным классом мы не ограничены числами. Мы вполне можем использовать строки или сложные объекты:

const stringNumeric = new GenericNumber<string>()
stringNumeric.zeroValue = ''
stringNumeric.add = (x, y) => x + y

console.log(stringNumeric.add(stringNumeric.zeroValue, 'test'))

Класс имеет две стороны с точки зрения типов: статическую сторону и сторону экземпляров. Общие классы являются общими только для экземпляров. Это означает, что статические члены класса не могут использовать тип параметра класса.

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

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

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length)
  // Property 'length' does not exist on type 'Type'.
  return arg
}

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

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

interface Lengthwise {
  length: number
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length)
  // Теперь мы можем быть уверены в существовании свойства `length`
  return arg
}

Поскольку дженерик был ограничен, он больше не может работать с любым типом:

loggingIdentity(3)
// Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.
// Аргумент типа 'number' не может быть присвоен параметру типа 'Lengthwise'

Мы должны передавать ему значения, отвечающие всем установленным требованиям:

loggingIdentity({ length: 10, value: 3 })

Использование типов параметров в ограничениях дженериков

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

function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key]
}

const x = { a: 1, b: 2, c: 3, d: 4 }

getProperty(x, 'a')
getProperty(x, 'm')
// Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.

Использование типов класса в дженериках

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

function create<Type>(c: { new (): Type }): Type {
  return new c()
}

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

class BeeKeeper {
  hasMask: boolean
}

class ZooKeeper {
  nametag: string
}

class Animal {
  numLegs: number
}

class Bee extends Animal {
  keeper: BeeKeeper
}

class Lion extends Animal {
  keeper: ZooKeeper
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c()
}

createInstance(Lion).keeper.nametag
createInstance(Bee).keeper.hasMask

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

Оператор типа keyof

Оператор keyof "берет" объектный тип и возвращает строковое или числовое литеральное объединение его ключей:

type Point = { x: number; y: number }
type P = keyof Point
// type P = keyof Point

Если типом сигнатуры индекса (index signature) типа является string или number, keyof возвращает эти типы:

type Arrayish = { [n: number]: unknown }
type A = keyof Arrayish
// type A = number

type Mapish = { [k: string]: boolean }
type M = keyof Mapish
// type M = string | number

:::note Обратите внимание

Типом M является string | number. Это объясняется тем, что ключи объекта в JS всегда преобразуются в строку, поэтому obj[0] - это всегда тоже самое, что obj['0'].

:::

Типы keyof являются особенно полезными в сочетании со связанными типами (mapped types), которые мы рассмотрим позже.

Оператор типа typeof

JS предоставляет оператор typeof, который можно использовать в контексте выражения:

console.log(typeof 'Привет, народ!') // string

В TS оператор typeof используется в контексте типа для ссылки на тип переменной или свойства:

const s = 'привет'
const n: typeof s
// const n: string

В сочетании с другими операторами типа мы можем использовать typeof для реализации нескольких паттернов. Например, давайте начнем с рассмотрения предопределенного типа ReturnType<T>. Он принимает тип функции и производит тип возвращаемого функцией значения:

type Predicate = (x: unknown) => boolean
type K = ReturnType<Predicate>
// type K = boolean

Если мы попытаемся использовать название функции в качестве типа параметра ReturnType, то получим ошибку:

function f() {
  return { x: 10, y: 3 }
}
type P = ReturnType<f>
// 'f' refers to a value, but is being used as a type here. Did you mean 'typeof f'?
// 'f' является ссылкой на значение, но используется как тип. Возможно, вы имели ввиду 'typeof f'

Запомните: значения и типы - это не одно и тоже. Для ссылки на тип значения f следует использовать typeof:

function f() {
  return { x: 10, y: 3 }
}
type P = ReturnType<typeof f>
// type P = { x: number, y: number }

Ограничения

TS ограничивает виды выражений, на которых можно использовать typeof.

typeof можно использовать только в отношении идентификаторов (названий переменных) или их свойств. Это помогает избежать написания кода, который не выполняется:

// Должны были использовать ReturnType<typeof msgbox>, но вместо этого написали
const shouldContinue: typeof msgbox('Вы уверены, что хотите продолжить?')
// ',' expected

Типы доступа по индексу (indexed access types)

Мы можем использовать тип доступа по индексу для определения другого типа:

type Person = { age: number; name: string; alive: boolean }
type Age = Person['age']
// type Age = number

Индексированный тип - это обычный тип, так что мы можем использовать объединения, keyof и другие типы:

type I1 = Person['age' | 'name']
// type I1 = string | number

type I2 = Person[keyof Person]
// type I2 = string | number | boolean

type AliveOrName = 'alive' | 'name'
type I3 = Person[AliveOrName]
// type I3 = string | boolean

При попытке доступа к несуществующему свойству возникает ошибка:

type I1 = Person['alve']
// Property 'alve' does not exist on type 'Person'.

Другой способ индексации заключается в использовании number для получения типов элементов массива. Мы также можем использовать typeof для перехвата типа элемента:

const MyArray = [
  { name: 'Alice', age: 15 },
  { name: 'Bob', age: 23 },
  { name: 'John', age: 38 }
]

type Person = typeof MyArray[number]

type Person = {
  name: string
  age: number
}
type Age = typeof MyArray[number]['age']

type Age = number
// или
type Age2 = Person['age']

type Age2 = number

:::note Обратите внимание

Мы не можем использовать const, чтобы сослаться на переменную:

:::

const key = 'age'
type Age = Person[key]
/*
  Type 'any' cannot be used as an index type.
  'key' refers to a value, but is being used as a type here. Did you mean 'typeof key'?
*/
/*
  Тип 'any' не может быть использован в качестве типа индекса.
  'key' является ссылкой на значение, но используется как тип. Возможно, вы имели ввиду 'typeof key'
*/

Однако, в данном случае мы можем использовать синоним типа (type alias):

type key = 'age'
type Age = Person[key]

Условные типы (conditional types)

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

interface Animal {
  live(): void
}
interface Dog extends Animal {
  woof(): void
}

type Example1 = Dog extends Animal ? number : string
// type Example1 = number

type Example2 = RegExp extends Animal ? number : string
// type Example2 = string

Условные типы имеют форму, схожую с условными выражениями в JS (условие ? истинноеВыражение : ложноеВыражение).

SomeType extends OtherType ? TrueType : FalseType

Когда тип слева от extends может быть присвоен типу справа от extends, мы получаем тип из первой ветки (истинной), в противном случае, мы получаем тип из второй ветки (ложной).

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

Рассмотрим такую функцию:

interface IdLabel {
  id: number /* некоторые поля */
}
interface NameLabel {
  name: string /* другие поля */
}

function createLabel(id: number): IdLabel
function createLabel(name: string): NameLabel
function createLabel(nameOrId: string | number): IdLabel | NameLabel
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw 'не реализовано'
}

Перегрузки createLabel описывают одну и ту же функцию, которая делает выбор на основе типов входных данных.

:::note Обратите внимание

  1. Если библиотека будет выполнять такую проверку снова и снова, это будет не очень рациональным.
  2. Нам пришлось создать 3 перегрузки: по одной для каждого случая, когда мы уверены в типе (одну для string и одну для number), и еще одну для общего случая (string или number). Количество перегрузок будет увеличиваться пропорционально добавлению новых типов.

:::

Вместо этого, мы можем реализовать такую же логику с помощью условных типов:

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel

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

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw 'не реализовано'
}

let a = createLabel('typescript')
// let a: NameLabel

let b = createLabel(2.8)
// let b: IdLabel

let c = createLabel(Math.random() ? 'hello' : 42)
// let c: NameLabel | IdLabel

Ограничения условных типов

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

Рассмотрим такой пример:

type MessageOf<T> = T['message']
// Type '"message"' cannot be used to index type 'T'.
// Тип '"message"' не может быть использован для индексации типа 'T'

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

type MessageOf<T extends { message: unknown }> = T['message']

interface Email {
  message: string
}

interface Dog {
  bark(): void
}

type EmailMessageContents = MessageOf<Email>
// type EmailMessageContents = string

Но что если мы хотим, чтобы MessageOf принимал любой тип, а его "дефолтным" значением был тип never? Мы можем "вынести" ограничение и использовать условный тип:

type MessageOf<T> = T extends { message: unknown } ? T['message'] : never

interface Email {
  message: string
}

interface Dog {
  bark(): void
}

type EmailMessageContents = MessageOf<Email>
// type EmailMessageContents = string

type DogMessageContents = MessageOf<Dog>
// type DogMessageContents = never

Находясь внутри истинной ветки, TS будет знать, что T имеет свойство message.

В качестве другого примера мы можем создать тип Flatten, который распаковывает типы массива на типы составляющих его элементов, но при этом сохраняет их в изоляции:

type Flatten<T> = T extends any[] ? T[number] : T

// Извлекаем тип элемента
type Str = Flatten<string[]>
// type Str = string

// Сохраняем тип
type Num = Flatten<number>
// type Num = number

Когда Flatten получает тип массива, он использует доступ по индексу с помощью number для получения типа элемента string[]. В противном случае, он просто возвращает переданный ему тип.

Предположения в условных типах

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

Условные типы предоставляют возможность делать предположения на основе сравниваемых в истинной ветке типов с помощью ключевого слова infer. Например, мы можем сделать вывод относительно типа элемента во Flatten вместо его получения вручную через доступ по индексу:

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type

В данном случае мы использовали ключевое слово infer для декларативного создания нового дженерика Item вместо извлечения типа элемента T в истинной ветке. Это избавляет нас от необходимости "копаться" и изучать структуру типов, которые нам необходимы.

Мы можем создать несколько вспомогательных синонимов типа (type aliases) с помощью infer. Например, в простых случаях мы можем извлекать возвращаемый тип из функции:

type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never

type Num = GetReturnType<() => number>
// type Num = number

type Str = GetReturnType<(x: string) => string>
// type Str = string

type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>
// type Bools = boolean[]

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

declare function stringOrNum(x: string): number
declare function stringOrNum(x: number): string
declare function stringOrNum(x: string | number): string | number

type T1 = ReturnType<typeof stringOrNum>
// type T1 = string | number

Распределенные условные типы (distributive conditional types)

Когда условные типы применяются к дженерикам, они становятся распределенными при получении объединения (union). Рассмотрим следующий пример:

type ToArray<Type> = Type extends any ? Type[] : never

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

type ToArray<Type> = Type extends any ? Type[] : never

type StrArrOrNumArr = ToArray<string | number>
// type StrArrOrNumArr = string[] | number[]

Здесь StrOrNumArray распределяется на:

string | number

и применяется к каждому члену объединения:

ToArray<string> | ToArray<number>

что приводит к следующему:

string[] | number[]

Обычно, такое поведение является ожидаемым. Для его изменения можно обернуть каждую сторону extends в квадратные скобки:

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never

// 'StrOrNumArr' больше не является объединением
type StrOrNumArr = ToArrayNonDist<string | number>
// type StrOrNumArr = (string | number)[]

Связанные типы (mapped types)

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

type OnlyBoolsAndHorses = {
  [key: string]: boolean | Horse
}

const conforms: OnlyBoolsAndHorses = {
  del: true,
  rodney: false
}

Связанный тип - это общий тип, использующий объединение, созданное с помощью оператора keyof, для перебора ключей одного типа в целях создания другого:

type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean
}

В приведенном примере OptionsFlag получит все свойства типа Type и изменит их значения на boolean.

type FeatureFlags = {
  darkMode: () => void
  newUserProfile: () => void
}

type FeatureOptions = OptionsFlags<FeatureFlags>
// type FeatureOptions = { darkMode: boolean, newUserProfile: boolean }

Модификаторы связывания (mapping modifiers)

Существует два модификатора, которые могут применяться в процессе связывания типов: readonly и ?, отвечающие за иммутабельность (неизменность) и опциональность, соответственно.

Эти модификаторы можно добавлять и удалять с помощью префиксов - или +. Если префикс отсутствует, предполагается +.

// Удаляем атрибуты `readonly` из свойств типа
type CreateMutable<Type> = {
  -readonly [Property in keyof Type]: Type[Property]
}

type LockedAccount = {
  readonly id: string
  readonly name: string
}

type UnlockedAccount = CreateMutable<LockedAccount>
// type UnlockedAccount = { id: string, name: string }
// Удаляем атрибуты `optional` из свойств типа
type Concrete<Type> = {
  [Property in keyof Type]-?: Type[Property]
}

type MaybeUser = {
  id: string
  name?: string
  age?: number
}

type User = Concrete<MaybeUser>
// type User = { id: string, name: string, age: number }

Повторное связывание ключей с помощью as

В TS 4.1 и выше, можно использовать оговорку as для повторного связывания ключей в связанном типе:

type MappedTypeWithNewProperties<Type> = {
  [Properties in keyof Type as NewKeyType]: Type[Properties]
}

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

type Getters<Type> = {
  [Property in keyof Type as `get${Capitalize<
    string & Property
  >}`]: () => Type[Property]
}

interface Person {
  name: string
  age: number
  location: string
}

type LazyPerson = Getters<Person>
// type LazyPerson = { getName: () => string, getAge: () => number, getLocation: () => string }

Ключи можно фильтровать с помощью never в условном типе:

// Удаляем свойство `kind`
type RemoveKindField<Type> = {
  [Property in keyof Type as Exclude<Property, 'kind'>]: Type[Property]
}

interface Circle {
  kind: 'circle'
  radius: number
}

type KindlessCircle = RemoveKindField<Circle>
// type KindlessCircle = { radius: number }

Связанные типы хорошо работают с другими возможностями по манипуляции типами, например, с условными типами. В следующем примере условный тип возвращает true или false в зависимости от того, содержит ли объект свойство pii с литерально установленным true:

type ExtractPII<Type> = {
  [Property in keyof Type]: Type[Property] extends { pii: true } ? true : false
}

type DBFields = {
  id: { format: 'incrementing' }
  name: { type: string; pii: true }
}

type ObjectsNeedingGDPRDeletion = ExtractPII<DBFields>
// type ObjectsNeedingGDPRDeletion = { id: false, name: true }

Типы шаблонных литералов (template literal types)

Типы шаблонных литералов основаны на типах строковых литералов и имеют возможность превращаться в несколько строк через объединения.

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

type World = 'world'

type Greeting = `hello ${World}`
// type Greeting = 'hello world'

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

type EmailLocaleIDs = 'welcome_email' | 'email_heading'
type FooterLocaleIDs = 'footer_title' | 'footer_sendoff'

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`
/*
  type AllLocaleIDs = 'welcome_email_id' | 'email_heading_id' | 'footer_title_id' | 'footer_sendoff_id'
*/

Для каждой интерполированной позиции в шаблонном литерале объединения являются множественными:

type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`
type Lang = 'en' | 'ja' | 'pt'

type LocaleMessageIDs = `${Lang}_${AllLocaleIDs}`
/*
  type LocaleMessageIDs = 'en_welcome_email_id' | 'en_email_heading_id' | 'en_footer_title_id' | 'en_footer_sendoff_id' | 'ja_welcome_email_id' | 'ja_email_heading_id' | 'ja_footer_title_id' | 'ja_footer_sendoff_id' | 'pt_welcome_email_id' | 'pt_email_heading_id' | 'pt_footer_title_id' | 'pt_footer_sendoff_id'
*/

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

Строковые объединения в типах

Мощь шаблонных строк в полной мере проявляется при определении новой строки на основе существующей внутри типа.

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

const person = makeWatchedObject({
  firstName: 'John',
  lastName: 'Smith',
  age: 30
})

person.on('firstNameChanged', (newValue) => {
  console.log(`Имя было изменено на ${newValue}!`)
})

:::note Обратите внимание

on регистрирует событие firstNameChanged, а не просто firstName.

:::

Шаблонные литералы предоставляют способ обработки такой операции внутри системы типов:

type PropEventSource<Type> = {
  on(
    eventName: `${string & keyof Type}Changed`,
    callback: (newValue: any) => void
  ): void
}

// Создаем "наблюдаемый объект" с методом `on`,
// позволяющим следить за изменениями значений свойств
declare function makeWatchedObject<Type>(
  obj: Type
): Type & PropEventSource<Type>

При передаче неправильного свойства возникает ошибка:

const person = makeWatchedObject({
  firstName: 'John',
  lastName: 'Smith',
  age: 26
})

person.on('firstNameChanged', () => {})

person.on('firstName', () => {})
// Argument of type '"firstName"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.
// Параметр типа '"firstName"' не может быть присвоен типу...

person.on('firstNameChanged', () => {})
// Argument of type '"firstNameChanged"' is not assignable to parameter of type '"firstNameChanged" | "lastNameChanged" | "ageChanged"'.

Предположения типов с помощью шаблонных литералов

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

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

type PropEventSource<Type> = {
  on<Key extends string & keyof Type>
    // (eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void ): void
}

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>

const person = makeWatchedObject({
  firstName: 'Jane',
  lastName: 'Air',
  age: 26
})

person.on('firstNameChanged', newName => {
                          // (parameter) newName: string
    console.log(`Новое имя - ${newName.toUpperCase()}`)
})

person.on('ageChanged', newAge => {
                    // (parameter) newAge: number
    if (newAge < 0) {
        console.warn('Предупреждение! Отрицательный возраст')
    }
})

Здесь мы реализовали on в общем методе.

При вызове пользователя со строкой firstNameChanged, TS попытается предположить правильный тип для Key. Для этого TS будет искать совпадения Key с "контентом", находящимся перед Changed, и дойдет до строки firstName. После этого метод on сможет получить тип firstName из оригинального объекта, чем в данном случае является string. Точно также при вызове с ageChanged, TS обнаружит тип для свойства age, которым является number.

Внутренние типы манипуляций со строками (intrinsic string manipulation types)

TS предоставляет несколько типов, которые могут использоваться при работе со строками. Эти типы являются встроенными и находятся в файлах .d.ts, создаваемых TS.

  • Uppercase<StringType> - переводит каждый символ строки в верхний регистр
type Greeting = 'Hello, world'
type ShoutyGreeting = Uppercase<Greeting>
// type ShoutyGreeting = 'HELLO, WORLD'

type ASCIICacheKey<Str extends string> = `ID-${Uppercase<Str>}`
type MainID = ASCIICacheKey<'my_app'>
// type MainID = 'ID-MY_APP'
  • Lowercase<StringType> - переводит каждый символ в строке в нижний регистр
type Greeting = 'Hello, world'
type QuietGreeting = Lowercase<Greeting>
// type QuietGreeting = 'hello, world'

type ASCIICacheKey<Str extends string> = `id-${Lowercase<Str>}`
type MainID = ASCIICacheKey<'MY_APP'>
// type MainID = 'id-my_app'
  • Capitilize<StringType> - переводит первый символ строки в верхний регистр
type LowercaseGreeting = 'hello, world'
type Greeting = Capitalize<LowercaseGreeting>
// type Greeting = 'Hello, world'
  • Uncapitilize<StringType> - переводит первый символ строки в нижний регистр
type UppercaseGreeting = 'HELLO WORLD'
type UncomfortableGreeting = Uncapitalize<UppercaseGreeting>
// type UncomfortableGreeting = 'hELLO WORLD'

Вот как эти типы реализованы:

function applyStringMapping(symbol: Symbol, str: string) {
  switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
    case IntrinsicTypeKind.Uppercase:
      return str.toUpperCase()
    case IntrinsicTypeKind.Lowercase:
      return str.toLowerCase()
    case IntrinsicTypeKind.Capitalize:
      return str.charAt(0).toUpperCase() + str.slice(1)
    case IntrinsicTypeKind.Uncapitalize:
      return str.charAt(0).toLowerCase() + str.slice(1)
  }
  return str
}

Классы

Члены класса (class members)

Вот пример самого простого класса - пустого:

class Point {}

Такой класс бесполезен, поэтому давайте добавим ему несколько членов.

Поля

Поле - это открытое (публичное) и доступное для записи свойство класса:

class Point {
  x: number
  y: number
}

const pt = new Point()
pt.x = 0
pt.y = 0

Аннотация типа является опциональной (необязательной), но неявный тип будет иметь значение any.

Поля могут иметь инициализаторы, которые автоматически запускаются при инстанцировании класса:

class Point {
  x = 0
  y = 0
}

const pt = new Point()
// Вывод: 0, 0
console.log(`${pt.x}, ${pt.y}`)

Как и в случае с const, let и var, инициализатор свойства класса используется для предположения типа этого свойства:

const pt = new Point()
pt.x = '0'
// Type 'string' is not assignable to type 'number'.
// Тип 'string' не может быть присвоен типу 'number'

--strictPropertyInitialization

Настройка strictPropertyInitialization определяет, должны ли поля класса инициализироваться в конструкторе.

class BadGreeter {
  name: string
  // Property 'name' has no initializer and is not definitely assigned in the constructor.
  // Свойство 'name' не имеет инициализатора и ему не присваивается значения в конструкторе
}

class GoodGreeter {
  name: string

  constructor() {
    this.name = 'привет'
  }
}

:::note Обратите внимание

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

:::

Если вы намерены инициализировать поле вне конструктора, можете использовать оператор утверждения определения присвоения (definite assignment assertion operator, !):

class OKGreeter {
  // Не инициализируется, но ошибки не возникает
  name!: string
}
readonly

Перед названием поля можно указать модификатор readonly. Это запретит присваивать полю значения за пределами конструктора.

class Greeter {
  readonly name: string = 'народ'

  constructor(otherName?: string) {
    if (otherName !== undefined) {
      this.name = otherName
    }
  }

  err() {
    this.name = 'не ok'
    // Cannot assign to 'name' because it is a read-only property.
    // Невозможно присвоить значение свойству 'name', поскольку оно является доступным только для чтения
  }
}
const g = new Greeter()
g.name = 'тоже не ok'
// Cannot assign to 'name' because it is a read-only property.

Конструкторы

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

class Point {
  x: number
  y: number

  // Обычная сигнатура с "дефолтными" значениями
  constructor(x = 0, y = 0) {
    this.x = x
    this.y = y
  }
}

class Point {
  // Перегрузки
  constructor(x: number, y: string)
  constructor(s: string)
  constructor(xs: any, y?: any) {
    // ...
  }
}

Однако, между сигнатурами конструктора класса и функции существует несколько отличий:

  • Конструкторы не могут иметь параметров типа - это задача возлагается на внешнее определение класса, о чем мы поговорим позже
  • Конструкторы не могут иметь аннотацию возвращаемого типа - всегда возвращается тип экземпляра класса

super

Как и в JS, при наличии базового класса в теле конструктора, перед использованием this необходимо вызывать super():

class Base {
  k = 4
}

class Derived extends Base {
  constructor() {
    // В ES5 выводится неправильное значение, в ES6 выбрасывается исключение
    console.log(this.k)
    // 'super' must be called before accessing 'this' in the constructor of a derived class.
    // Перед доступом к 'this' в конструкторе или производном классе необходимо вызвать 'super'
    super()
  }
}

В JS легко забыть о необходимости вызова super, в TS - почти невозможно.

Методы

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

class Point {
  x = 10
  y = 10

  scale(n: number): void {
    this.x *= n
    this.y *= n
  }
}

Как видите, TS не добавляет к методам ничего нового.

:::note Обратите внимание

В теле метода к полям и другим методам по-прежнему следует обращаться через this. Неквалифицированное название (unqualified name) в теле функции всегда будет указывать на лексическое окружение.

:::

let x: number = 0

class C {
  x: string = 'привет'

  m() {
    // Здесь мы пытаемся изменить значение переменной `x`, находящейся на первой строке, а не свойство класса
    x = 'world'
    // Type 'string' is not assignable to type 'number'.
  }
}

Геттеры/сеттеры

Классы могут иметь акцессоры (вычисляемые свойства, accessors):

class C {
  _length = 0
  get length() {
    return this._length
  }
  set length(value) {
    this._length = value
  }
}

TS имеет несколько специальных правил, касающихся предположения типов в случае с акцессорами:

  • Если set отсутствует, свойство автоматически становится readonly
  • Параметр типа сеттера предполагается на основе типа, возвращаемого геттером
  • Если параметр сеттера имеет аннотацию типа, она должна совпадать с типом, возвращаемым геттером
  • Геттеры и сеттеры должны иметь одинаковую видимость членов (см. ниже)

Если есть геттер, но нет сеттера, свойство автоматически становится readonly.

Сигнатуры индекса (index signatures)

Классы могут определять сигнатуры индекса. Они работают также, как сигнатуры индекса в других объектных типах:

class MyClass {
  [s: string]: boolean | ((s: string) => boolean)

  check(s: string) {
    return this[s] as boolean
  }
}

Обычно, индексированные данные лучше хранить в другом месте.

Классы и наследование

Как и в других объектно-ориентированных языках, классы в JS могут наследовать членов других классов.

implements

implements используется для проверки соответствия класса определенному interface. При несоответствии класса интерфейсу возникает ошибка:

interface Pingable {
  ping(): void
}

class Sonar implements Pingable {
  ping() {
    console.log('пинг!')
  }
}

class Ball implements Pingable {
  // Class 'Ball' incorrectly implements interface 'Pingable'. Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.
  // Класс 'Ball' некорректно реализует интерфейс 'Pingable'. Свойство 'ping' отсутствует в типе 'Ball', но является обязательным в типе 'Pingable'
  pong() {
    console.log('понг!')
  }
}

Классы могут реализовывать несколько интерфейсов одновременно, например, class C implements A, B {}.

Предостережение

Важно понимать, что implements всего лишь проверяет, соответствует ли класс определенному интерфейсу. Он не изменяет тип класса или его методов. Ошибочно полагать, что implements изменяет тип класса - это не так!

interface Checkable {
  check(name: string): boolean
}

class NameChecker implements Checkable {
  check(s) {
    // Parameter 's' implicitly has an 'any' type.
    // Неявным типом параметра 's' является 'any'
    // Обратите внимание, что ошибки не возникает
    return s.toLowercse() === 'ok'
    // any
  }
}

В приведенном примере мы, возможно, ожидали, что тип s будет определен на основе name: string в check. Это не так - implements не меняет того, как проверяется тело класса или предполагаются его типы.

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

interface A {
  x: number
  y?: number
}
class C implements A {
  x = 0
}
const c = new C()
c.y = 10
// Property 'y' does not exist on type 'C'.
// Свойства с названием 'y' не существует в типе 'C'

extends

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

class Animal {
  move() {
    console.log('Moving along!')
  }
}

class Dog extends Animal {
  woof(times: number) {
    for (let i = 0; i < times; i++) {
      console.log('woof!')
    }
  }
}

const d = new Dog()
// Метод базового класса
d.move()
// Метод производного класса
d.woof(3)

Перезапись методов

Производный класс может перезаписывать свойства и методы базового класса. Для доступа к методам базового класса можно использовать синтаксис super. Поскольку классы в JS - это всего лишь объекты для поиска (lookup objects), такого понятия как "супер-поле" не существует.

TS обеспечивает, чтобы производный класс всегда был подтипом базового класса.

Пример "легального" способа перезаписи метода:

class Base {
  greet() {
    console.log('Привет, народ!')
  }
}

class Derived extends Base {
  greet(name?: string) {
    if (name === undefined) {
      super.greet()
    } else {
      console.log(`Привет, ${name.toUpperCase()}`)
    }
  }
}

const d = new Derived()
d.greet()
d.greet('читатель!')

Важно, чтобы производный класс следовал контракту базового класса. Помните, что очень часто (и всегда легально) ссылаться на экземпляр производного класса через указатель на базовый класс:

// Создаем синоним для производного экземпляра с помощью ссылки на базовый класс
const b: Base = d
// Все работает
b.greet()

Что если производный класс не будет следовать контракту базового класса?

class Base {
  greet() {
    console.log('Привет, народ!')
  }
}

class Derived extends Base {
  // Делаем этот параметр обязательным
  greet(name: string) {
    // Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'. Type '(name: string) => void' is not assignable to type '() => void'.
    // Свойство 'greet' в типе 'Derived' не может быть присвоено одноименному свойству в базовом типе 'Base'...
    console.log(`Привет, ${name.toUpperCase()}`)
  }
}

Если мы скомпилируем этот код, несмотря на ошибку, такой "сниппет" провалится:

const b: Base = new Derived()
// Не работает, поскольку `name` имеет значение `undefined`
b.greet()

Порядок инициализации

Порядок инициализации классов может быть неожиданным. Рассмотрим пример:

class Base {
  name = 'базовый'
  constructor() {
    console.log('Меня зовут ' + this.name)
  }
}

class Derived extends Base {
  name = 'производный'
}

// Вывод: 'базовый', а не 'производный'
const d = new Derived()

Что здесь происходит?

Порядок инициализации согласно спецификации следующий:

  • Инициализация полей базового класса
  • Запуск конструктора базового класса
  • Инициализация полей производного класса
  • Запуск конструктора производного класса

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

Наследование встроенных типов

В ES2015 конструкторы, неявно возвращающие объекты, заменяют значение this для любого вызова super. Для генерируемого конструктора важно перехватывать потенциальное значение, возвращаемое super, и заменять его значением this.

Поэтому подклассы Error, Array и др. могут работать не так, как ожидается. Это объясняется тем, что Error, Array и др. используют new.target из ES6 для определения цепочки прототипов; определить значение new.target в ES5 невозможно. Другие компиляторы, обычно, имеют такие же ограничения.

Для такого подкласса:

class MsgError extends Error {
  constructor(m: string) {
    super(m)
  }
  sayHello() {
    return 'Привет ' + this.message
  }
}

вы можете обнаружить, что:

  • методы объектов, возвращаемых при создании подклассов, могут иметь значение undefined, поэтому вызов sayHello завершится ошибкой
  • instanceof сломается между экземплярами подкласса и их экземплярами, поэтому (new MsgError()) instanceof MsgError возвращает false

Для решения данной проблемы можно явно устанавливать прототип сразу после вызова super.

class MsgError extends Error {
  constructor(m: string) {
    super(m)

    // Явно устанавливаем прототип
    Object.setPrototypeOf(this, MsgError.prototype)
  }

  sayHello() {
    return 'Привет ' + this.message
  }
}

Тем не менее, любой подкласс MsgError также должен будет вручную устанавливать прототип. В среде выполнения, в которой не поддерживается Object.setPrototypeOf, можно использовать __proto__.

Видимость членов (member visibility)

Мы можем использовать TS для определения видимости методов и свойств для внешнего кода, т.е. кода, находящегося за пределами класса.

public

По умолчанию видимость членов класса имеет значение public. Публичный член доступен везде:

class Greeter {
  public greet() {
    console.log('Привет!')
  }
}
const g = new Greeter()
g.greet()

Поскольку public является дефолтным значением, специально указывать его не обязательно, но это повышает читаемость и улучшает стиль кода.

protected

Защищенные члены видимы только для подклассов класса, в котором они определены.

class Greeter {
  public greet() {
    console.log('Привет, ' + this.getName())
  }
  protected getName() {
    return 'народ!'
  }
}

class SpecialGreeter extends Greeter {
  public howdy() {
    // Здесь защищенный член доступен
    console.log('Здорово, ' + this.getName())
  }
}
const g = new SpecialGreeter()
g.greet() // OK
g.getName()
// Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.
// Свойство 'getName' является защищенным и доступно только в классе 'Greeter' и его подклассах

Раскрытие защищенных членов

Производные классы должны следовать контракту базового класса, но могут расширять подтипы базового класса дополнительными возможностями. Это включает в себя перевод protected членов в статус public:

class Base {
  protected m = 10
}
class Derived extends Base {
  // Модификатор отсутствует, поэтому значением по умолчанию является `public`
  m = 15
}
const d = new Derived()
console.log(d.m) // OK

:::note Обратите внимание

В производной классе для сохранения "защищенности" члена необходимо повторно указывать модификатор protected.

:::

Доступ к защищенным членам за пределами иерархии классов

Разные языки ООП по-разному подходят к доступу к защищенным членам из базового класса:

class Base {
  protected x: number = 1
}
class Derived1 extends Base {
  protected x: number = 5
}
class Derived2 extends Base {
  f1(other: Derived2) {
    other.x = 10
  }
  f2(other: Base) {
    other.x = 10
    // Property 'x' is protected and only accessible through an instance of class 'Derived2'. This is an instance of class 'Base'.
    // Свойство 'x' является защищенным и доступно только через экземпляр класса 'Derived2'. А это - экземпляр класса 'Base'
  }
}

Java, например, считает такой подход легальным, а C# и C++ нет.

TS считает такой подход нелегальным, поскольку доступ к x из Derived2 должен быть легальным только в подклассах Derived2, а Derived1 не является одним из них.

private

Частные члены похожи на защищенные, но не доступны даже в подклассах, т.е. они доступны только в том классе, где они определены.

class Base {
  private x = 0
}
const b = new Base()
// Снаружи класса доступ получить нельзя
console.log(b.x)
// Property 'x' is private and only accessible within class 'Base'.

class Derived extends Base {
  showX() {
    // В подклассе доступ получить также нельзя
    console.log(this.x)
    // Property 'x' is private and only accessible within class 'Base'.
  }
}

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

class Base {
  private x = 0
}
class Derived extends Base {
  // Class 'Derived' incorrectly extends base class 'Base'. Property 'x' is private in type 'Base' but not in type 'Derived'.
  // Класс 'Derived' неправильно расширяет базовый класс 'Base'. Свойство 'x' является частным в типе 'Base', но не в типе 'Derived'
  x = 1
}

Доступ к защищенным членам между экземплярами

Разные языки ООП также по-разному подходят к предоставлению доступа экземплярам одного класса к защищенным членам друг друга. Такие языки как Java, C#, C++, Swift и PHP разрешают такой доступ, а Ruby нет.

TS разрешает такой доступ:

class A {
  private x = 10

  public sameAs(other: A) {
    // Ошибки не возникает
    return other.x === this.x
  }
}

Предостережение

Подобно другим аспектам системы типов TS, private и protected оказывают влияние на код только во время проверки типов. Это означает, что конструкции вроде in или простой перебор свойств имеют доступ к частным и защищенным членам:

class MySafe {
  private secretKey = 12345
}

// В JS-файле...
const s = new MySafe()
// Вывод 12345
console.log(s.secretKey)

Для реализации "настоящих" частных членов можно использовать такие механизмы, как замыкания (closures), слабые карты (weak maps) или синтаксис приватных полей класса (private fields, #).

Статические члены (static members)

В классах могут определеяться статические члены. Такие члены не связаны с конкретными экземплярами класса. Они доступны через объект конструктора класса:

class MyClass {
  static x = 0
  static printX() {
    console.log(MyClass.x)
  }
}
console.log(MyClass.x)
MyClass.printX()

К статическим членам также могут применяться модификаторы public, protected и private:

class MyClass {
  private static x = 0
}
console.log(MyClass.x)
// Property 'x' is private and only accessible within class 'MyClass'.

Статические члены наследуются:

class Base {
  static getGreeting() {
    return 'Привет, народ!'
  }
}
class Derived extends Base {
  myGreeting = Derived.getGreeting()
}

Специальные названия статических членов

Изменение прототипа Function считается плохой практикой. Поскольку классы - это функции, вызываемые с помощью new, некоторые слова нельзя использовать в качестве названий статических членов. К таким словам относятся, в частности, свойства функций name, length и call:

class S {
  static name = 'S!'
  // Static property 'name' conflicts with built-in property 'Function.name' of constructor function 'S'.
  // Статическое свойство 'name' вступает в конфликт со встроенным свойством 'Function.name' функции-конструктора 'S'
}

Почему не существует статических классов?

В некоторых языках, таких как C# или Java существует такая конструкция, как статический класс (static class).

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

Например, нам не нужен синтаксис "статического класса", поскольку обычный объект (или функция верхнего уровня) прекрасно справляются с такими задачами:

// Ненужный "статический" класс
class MyStaticClass {
  static doSomething() {}
}

// Альтернатива 1
function doSomething() {}

// Альтернатива 2
const MyHelperObject = {
  dosomething() {}
}

Общие классы (generic classes)

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

class Box<Type> {
  contents: Type
  constructor(value: Type) {
    this.contents = value
  }
}

const b = new Box('Привет!')
// const b: Box<string>

В классах, как и в интерфейсах, могут использоваться ограничения дженериков и значения по умолчанию.

Параметр типа в статических членах

Следующий код, как ни странно, является НЕлегальным:

class Box<Type> {
  static defaultValue: Type
  // Static members cannot reference class type parameters.
  // Статические члены не могут ссылаться на типы параметров класса
}

Запомните, что типы полностью удаляются! Во время выполнения существует только один слот Box.defaultValue. Это означает, что установка Box<string>.defaultValue (если бы это было возможным) изменила бы Box<number>.defaultValue, что не есть хорошо. Поэтому статические члены общих классов не могут ссылаться на параметры типа класса.

Значение this в классах во время выполнения кода

TS не изменяет поведения JS во время выполнения. Обработка this в JS может показаться необычной:

class MyClass {
  name = 'класс'
  getName() {
    return this.name
  }
}
const c = new MyClass()
const obj = {
  name: 'объект',
  getName: c.getName
}

// Выводится 'объект', а не 'класс'
console.log(obj.getName())

Если кратко, то значение this внутри функции зависит от того, как эта функция вызывается. В приведенном примере, поскольку функция вызывается через ссылку на obj, значением this является obj, а не экземпляр класса.

TS предоставляет некоторые средства для изменения такого поведения.

Стрелочные функции

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

class MyClass {
  name = 'класс'
  getName = () => {
    return this.name
  }
}
const c = new MyClass()
const g = c.getName
// Выводится 'класс'
console.log(g())

Это требует некоторых компромиссов:

  • Значение this будет гарантированно правильным во время выполнения, даже в коде, не прошедшем проверки с помощью TS
  • Будет использоваться больше памяти, поскольку для каждого экземпляра класса будет создаваться новая функция
  • В производном классе нельзя будет использовать super.getName, поскольку отсутствует входная точка для получения метода базового класса в цепочке прототипов

Параметры this

При определении метода или функции начальный параметр под названием this имеет особое значение в TS. Данный параметр удаляется во время компиляции:

// TS
function fn(this: SomeType, x: number) {
  /* ... */
}

// JS
function fn(x) {
  /* ... */
}

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

class MyClass {
  name = 'класс'
  getName(this: MyClass) {
    return this.name
  }
}
const c = new MyClass()
// OK
c.getName()

// Ошибка
const g = c.getName
console.log(g())
// The 'this' context of type 'void' is not assignable to method's 'this' of type 'MyClass'.
// Контекст 'this' типа 'void' не может быть присвоен методу 'this' типа 'MyClass'

Данный подход также сопряжен с несколькими органичениями:

  • Мы все еще имеем возможность вызывать метод неправильно
  • Выделяется только одна функция для каждого определения класса, а не для каждого экземпляра класса
  • Базовые определения методов могут по-прежнему вызываться через super

Типы this

В классах специальный тип this динамически ссылается на тип текущего класса:

class Box {
  contents: string = ''
  set(value: string) {
    // (method) Box.set(value: string): this
    this.contents = value
    return this
  }
}

Здесь TS предполагает, что типом this является тип, возвращаемый set, а не Box. Создадим подкласс Box:

class ClearableBox extends Box {
  clear() {
    this.contents = ''
  }
}

const a = new ClearableBox()
const b = a.set('привет')
// const b: ClearableBox

Мы также можем использовать this в аннотации типа параметра:

class Box {
  content: string = ''
  sameAs(other: this) {
    return other.content === this.content
  }
}

Это отличается от other: Box - если у нас имеется производный класс, его метод sameAs будет принимать только другие экземпляры этого производного класса:

class Box {
  content: string = ''
  sameAs(other: this) {
    return other.content === this.content
  }
}

class DerivedBox extends Box {
  otherContent: string = '?'
}

const base = new Box()
const derived = new DerivedBox()
derived.sameAs(base)
// Argument of type 'Box' is not assignable to parameter of type 'DerivedBox'. Property 'otherContent' is missing in type 'Box' but required in type 'DerivedBox'.

Основанные на this защитники типа

Мы можем использовать this is Type в качестве возвращаемого типа в методах классов и интерфейсах. В сочетании с сужением типов (например, с помощью инструкции if), тип целевого объекта может быть сведен к более конкретному Type.

class FileSystemObject {
  isFile(): this is FileRep {
    return this instanceof FileRep
  }
  isDirectory(): this is Directory {
    return this instanceof Directory
  }
  isNetworked(): this is Networked & this {
    return this.networked
  }
  constructor(public path: string, private networked: boolean) {}
}

class FileRep extends FileSystemObject {
  constructor(path: string, public content: string) {
    super(path, false)
  }
}

class Directory extends FileSystemObject {
  children: FileSystemObject[]
}

interface Networked {
  host: string
}

const fso: FileSystemObject = new FileRep('foo/bar.txt', 'foo')

if (fso.isFile()) {
  fso.content
  // const fso: FileRep
} else if (fso.isDirectory()) {
  fso.children
  // const fso: Directory
} else if (fso.isNetworked()) {
  fso.host
  // const fso: Networked & FileSystemObject
}

Распространенным случаем использования защитников или предохранителей типа (type guards) на основе this является "ленивая" валидация определенного поля. В следующем примере мы удаляем undefined из значения, содержащегося в box, когда hasValue проверяется на истинность:

class Box<T> {
  value?: T

  hasValue(): this is { value: T } {
    return this.value !== undefined
  }
}

const box = new Box()
box.value = 'Gameboy'

box.value
// (property) Box<unknown>.value?: unknown

if (box.hasValue()) {
  box.value
  // (property) value: unknown
}

Свойства параметров

TS предоставляет специальный синтаксис для преобразования параметров конструктора в свойства класса с аналогичными названиями и значениями. Это называется свойствами параметров (или параметризованными свойствами), такие свойства создаются с помощью добавления модификаторов public, private, protected или readonly к аргументам конструктора. Создаваемые поля получают те же модификаторы:

class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // ...
  }
}
const a = new Params(1, 2, 3)
console.log(a.x)
// (property) Params.x: number
console.log(a.z)
// Property 'z' is private and only accessible within class 'Params'.

Выражения классов (class expressions)

Выражения классов похожи на определения классов. Единственным отличием между ними является то, что выражения классов не нуждаются в названии, мы можем ссылаться на них с помощью любого идентификатора, к которому они привязаны (bound):

const someClass = class<Type> {
  content: Type
  constructor(value: Type) {
    this.content = value
  }
}

const m = new someClass('Привет, народ!')
// const m: someClass<string>

Абстрактные классы и члены

Классы, методы и поля в TS могут быть абстрактными.

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

Абстрактные классы выступают в роли базовых классов для подклассов, которые реализуют абстрактных членов. При отсутствии абстрактных членов класс считается конкретным (concrete).

Рассмотрим пример:

abstract class Base {
  abstract getName(): string

  printName() {
    console.log('Привет, ' + this.getName())
  }
}

const b = new Base()
// Cannot create an instance of an abstract class.
// Невозможно создать экземпляр абстрактного класса

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

class Derived extends Base {
  getName() {
    return 'народ!'
  }
}

const d = new Derived()
d.printName()

:::note Обратите внимание

Если мы забудем реализовать абстрактных членов, то получим ошибку.

:::

class Derived extends Base {
  // Non-abstract class 'Derived' does not implement inherited abstract member 'getName' from class 'Base'.
  // Неабстрактный класс 'Derived' не реализует унаследованный от класса 'Base' абстрактный член 'getName'
  // Забыли про необходимость реализации абстрактных членов
}

Сигнатуры абстрактных конструкций (abstract construct signatures)

Иногда нам требуется конструктор класса, создающий экземпляр класса, производный от некоторого абстрактного класса.

Рассмотрим пример:

function greet(ctor: typeof Base) {
  const instance = new ctor()
  // Cannot create an instance of an abstract class.
  instance.printName()
}

TS сообщает нам о том, что мы пытаемся создать экземпляр абстрактного класса. Тем не менее, имея определение greet, мы вполне можем создать абстрактный класс:

// Плохо!
greet(Base)

Вместо этого, мы можем написать функцию, которая принимает нечто с сигнатурой конструктора:

function greet(ctor: new () => Base) {
  const instance = new ctor()
  instance.printName()
}
greet(Derived)
greet(Base)
/*
  Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.
    Cannot assign an abstract constructor type to a non-abstract constructor type.
*/
/*
  Аргумент типа 'typeof Base' не может быть присвоен параметру типа 'new () => Base'.
    Невозможно присвоить тип абстрактного конструктора типу неабстрактного конструктора
*/

Теперь TS правильно указывает нам на то, какой конструктор может быть вызван - Derived может, а Base нет.

Отношения между классами

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

Например, следующие два класса являются взаимозаменяемыми, поскольку они идентичны:

class Point1 {
  x = 0
  y = 0
}

class Point2 {
  x = 0
  y = 0
}

// OK
const p: Point1 = new Point2()

Также существуют отношения между подтипами, даже при отсутствии явного наследования:

class Person {
  name: string
  age: number
}

class Employee {
  name: string
  age: number
  salary: number
}

// OK
const p: Person = new Employee()

Однако, существует одно исключение.

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

class Empty {}

function fn(x: Empty) {
  // С `x` можно делать что угодно
}

// OK!
fn(window)
fn({})
fn(fn)

Модули

Определение модуля

В TS, как и в ECMAScript2015, любой файл, содержащий import или export верхнего уровня (глобальный), считается модулем.

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

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

Не модули

Для начала, давайте разберемся, что TS считает модулем. Спецификация JS определяет, что любой файл без export или await верхнего уровня является скриптом, а не модулем.

Переменные и типы, объявленные в скрипте, являются глобальными (имеют глобальную область видимости), для объединения нескольких файлов на входе в один на выходе следует использовать либо настроку компилятора outFile, либо несколько элементов script в разметке (указанных в правильном порядке).

Если у нас имеется файл, который не содержит import или export, но мы хотим, чтобы этот файл считался модулем, просто добавляем в него такую строку:

export {}

Модули в TS

Существует 3 вещи, на которые следует обращать внимание при работе с модулями в TS:

  • Синтаксис: какой синтаксис я хочу использовать для импорта и экспорта сущностей?
  • Разрешение модулей: каковы отношения между названиями модулей (или их путями) и файлами на диске?
  • Результат: на что должен быть похож код модуля?

Синтаксис

Основной экспорт в файле определяется с помощью export default:

// @filename: hello.ts
export default function helloWorld() {
  console.log('Привет, народ!')
}

Затем данная функция импортируется следующим образом:

import hello from './hello.js'
hello()

В дополнению к экспорту по умолчанию, из файла может экспортироваться несколько переменных и функций с помощью export (без default):

// @filename: maths.ts
export var pi = 3.14
export let squareTwo = 1.41
export const phi = 1.61

export class RandomNumberGenerator {}

export function absolute(num: number) {
  if (num < 0) return num * -1
  return num
}

Указанные сущности импортируются так:

import { pi, phi, absolute } from './maths.js'

console.log(pi)
const absPhi = absolute(phi)
// const absPhi: number

Дополнительный синтаксис импорта

Название импортируемой сущности можно менять с помощью import { old as new }:

import { pi as π } from './maths.js'

console.log(π)
/*
          (alias) var π: number
          import π
        */

Разные способы импорта можно смешивать:

// @filename: maths.ts
export const pi = 3.14
export default class RandomNumberGenerator {}

// @filename: app.ts
import RNGen, { pi as π } from './maths.js'

RNGen
/*
  (alias) class RNGen
  import RNGen
*/

console.log(π)
/*
  (alias) const π: 3.14
  import π
*/

Все экспортированные объекты при импорте можно поместить в одно пространство имен с помощью * as name:

// @filename: app.ts
import * as math from './maths.js'

console.log(math.pi)
const positivePhi = math.absolute(math.phi)
// const positivePhi: number

Файлы можно импортировать без указания переменных:

// @filename: app.ts
import './maths.js'

console.log('3.14')

В данном случае import ничего не делает. Тем не менее, весь код из maths.ts вычисляется (оценивается), что может привести к запуску побочных эффектов, влияющих на другие объекты.

Специфичный для TS синтаксис модулей

Типы могут экспортироваться и импортироваться с помощью такого же синтаксиса, что и значения в JS:

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number }

export interface Dog {
  breeds: string[]
  yearOfBirth: number
}

// @filename: app.ts
import { Cat, Dog } from './animal.js'
type Animals = Cat | Dog

TS расширяет синтаксис import с помощью import type, что позволяет импортировать только типы.

// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number }
// 'createCatName' cannot be used as a value because it was imported using 'import type'.
// 'createCatName' не может использоваться в качестве значения, поскольку импортируется с помощью 'import type'
export type Dog = { breeds: string[]; yearOfBirth: number }
export const createCatName = () => 'fluffy'

// @filename: valid.ts
import type { Cat, Dog } from './animal.js'
export type Animals = Cat | Dog

// @filename: app.ts
import type { createCatName } from './animal.js'
const name = createCatName()

Такой импорт сообщает транспиляторам, вроде Babel, swc или esbuild, какой импорт может быть безопасно удален.

Синтаксис ES-модулей с поведением CommonJS

Синтаксис ES-модулей в TS напрямую согласуется с CommonJS и require из AMD. Импорт с помощью ES-модулей в большинстве случаев представляет собой тоже самое, что require в указанных окружениях, он позволяет обеспечить полное совпадение TS-файла с результатом CommonJS:

import fs = require('fs')
const code = fs.readFileSync('hello.ts', 'utf8')

Синтаксис CommonJS

CommonJS - это формат, используемый большинством npm-пакетов. Даже если вы используете только синтаксис ES-модулей, понимание того, как работает CommonJS, поможет вам в отладке приложений.

Экспорт

Идентификаторы экпортируются посредством установки свойства exports глобальной переменной module:

function absolute(num: number) {
  if (num < 0) return num * -1
  return num
}

module.exports = {
  pi: 3.14,
  squareTwo: 1.41,
  phi: 1.61,
  absolute
}

Затем эти файлы импортируются с помощью инструкции require:

const maths = require('maths')
maths.pi
// any

В данном случае импорт можно упростить с помощью деструктуризации:

const { squareTwo } = require('maths')
squareTwo
// const squareTwo: any

Взаимодействие CommonJS с ES-модулями

Между CommonJS и ES-модулями имеется несовпадение, поскольку ES-модули поддерживают "дефолтный" экспорт только объектов, но не функций. Для преодоления данного несовпадения в TS используется флаг компиляции esModuleInterop.

Настройки, связанные с разрешением модулей

Разрешение модулей - это процесс определения файла, указанного в качестве ссылки в строке из инструкции import или require.

TS предоставляет две стратегии разрешения модулей: классическую и Node. Классическая стратегия является стратегией по умолчанию (когда флаг module имеет значение, отличное от commonjs) и включается для обеспечения обратной совместимости. Стратегия Node имитирует работу Node.js в режиме CommonJS с дополнительными проверками для .ts и .d.ts.

Существует большое количество флагов, связанных с разрешением модулей: moduleResolution, baseUrl, paths, rootDirs и др.

Настройки для результатов разрешения модулей

Имеется две настройки, которые влияют на результирующий JS-код:

  • target - определяет версию JS, в которую компилируется TS-код
  • module - определяет, какой код используется для взаимодействия модулей между собой

То, какую цель (target) использовать, зависит от того, в какой среде будет выполняться код (какие возможности поддерживаются этой средой). Это может включать в себя поддержку старых браузеров, более низкую версию Node.js или специфические ограничения, накладываемые такими средами выполнения, как, например, Electron.

Коммуникация между модулями происходит через загрузчик модулей (module loader), определяемый в настройке module. Во время выполнения загрузчик отвечает за локализацию и установку всех зависимостей модуля перед его выполнением.

Ниже приведено несколько примеров использования синтаксиса ES-модулей с разными настройками module:

import { valueOfPi } from './constants.js'

export const twoPi = valueOfPi * 2

ES2020

import { valueOfPi } from './constants.js'
export const twoPi = valueOfPi * 2

CommonJS

'use strict'
Object.defineProperty(exports, '__esModule', { value: true })
exports.twoPi = void 0
const constants_js_1 = require('./constants.js')
exports.twoPi = constants_js_1.valueOfPi * 2

UMD

;(function (factory) {
  if (typeof module === 'object' && typeof module.exports === 'object') {
    var v = factory(require, exports)
    if (v !== undefined) module.exports = v
  } else if (typeof define === 'function' && define.amd) {
    define(['require', 'exports', './constants.js'], factory)
  }
})(function (require, exports) {
  'use strict'
  Object.defineProperty(exports, '__esModule', { value: true })
  exports.twoPi = void 0
  const constants_js_1 = require('./constants.js')
  exports.twoPi = constants_js_1.valueOfPi * 2
})

Пространства имен (namespaces)

TS имеет собственный модульный формат, который называется namespaces. Данный синтаксис имеет множество полезных возможностей по созданию сложных файлов определений и по-прежнему активно используется в DefinitelyTyped. Несмотря на то, что namespaces не признаны устаревшими (deprecated), большая часть его возможностей нашла воплощение в ES-модулях, поэтому настоятельно рекомендуется использовать официальный синтаксис.