TypeScript
Основы
Каждое значение в 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())
Обратите внимание на две вещи
- Наши параметры
person
иdate
больше не имеют аннотаций типа. - Наша "шаблонная строка" - строка, в которой используются обратные кавычки (``) - была преобразована в обычную строку с конкатенациями (+).
Что касается первого пункта, то все дело в том, что аннотации типа не являются частью 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
Обратите внимание
Типы String
, Number
и Boolean
(начинающиеся с большой буквы) являются легальными и ссылаются на специальные встроенные типы, которые, однако, редко используются в коде. Для типов всегда следует использовать string
, number
или boolean
.
Массивы
Для определения типа массива [1, 2, 3]
можно использовать синтаксис number[]
; такой синтаксис подходит для любого типа (например, string[]
- это массив строк и т.д.). Также можно встретить Array<number>
, что означает то же самое. Такой синтаксис, обычно, используется для определения общих типов или дженериков (generics).
Обратите внимание
[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'
Обратите внимание
Количество передаваемых аргументов будет проверяться даже при отсутствии аннотаций типа параметров.
Аннотация типа возвращаемого функцией значения
Также можно аннотировать тип возвращаемого функцией значения:
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)
Обратите внимание
В литературе, посвященной 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
Обратите внимание
Синонимы - это всего лишь синонимы, мы не можем создавать на их основе другие "версии" типов. Например, такой код может выглядеть неправильным, но 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
const req = { url: 'https://example.com', method: 'GET' as 'GET' }
// Изменение 2
handleRequest(req.url, req.method as 'GET')
- Для преобразования объекта в литерал можно использовать
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 не является итерируемой (перебираемой) сущностью
Обратите внимание
Проверка примитивов на истинность также подвержена подобным ошибкам. Рассмотрим другую реализацию 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()
}
Обратите внимание
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
.
Обратите внимание
Название параметра является обязательным. Тип функции (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)}`)
}
Обратите внимание
Данный синтаксис немного отличается от типа-выражения функции - между параметрами и возвращаемым значением используется :
вместо =>
.
Сигнатуры конструктора (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))
Обратите внимание
В приведенном примере 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 = firstElement1([1, 2, 3])
// b: any (плохо)
const b = firstElement2([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 fn(x: boolean) {}
function fn(x: string): string
// Неправильный тип возвращаемого значения
function fn(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
})
Обратите внимание
В данном случае мы не можем использовать стрелочную функцию.
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
- это разные вещи.
Обратите внимание
void
- это не то же самое, что undefined
.
object
Специальный тип object
представляет значение, которое не является примитивом (string, number, boolean, symbol, null, undefined
). object
отличается от типа пустого объекта ({}
), а также от глобального типа Object
. Скорее всего, вам никогда не потребуется использовать Object
.
Правило: object
- это не Object
. Всегда используйте object
!
Обратите внимание
В 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)
Обратите внимание
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) => dst.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
, но являются опциональными при ее вызове.
Обратите внимание
В настоящее время не существует способа поместить аннотацию типа в деструктуризацию, поскольку такой синтаксис будет интерпретирован 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
Кроме общих интерфейсов, мы можем создавать общие классы.
Обратите внимание
Общие перечисления (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>()