Skip to main content

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

Предположим, что у нас имеется функция под названием 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
}
}