Мы продолжаем серию публикаций адаптированного и дополненного перевода "Карманной книги по TypeScript
".
Другие части:
- Часть 1. Основы
- Часть 2. Типы на каждый день
- Часть 3. Сужение типов
- Часть 4. Подробнее о функциях
- Часть 5. Объектные типы
- Часть 6. Манипуляции с типами
- Часть 7. Классы
- Часть 8. Модули
Обратите внимание: для большого удобства в изучении книга была оформлена в виде прогрессивного веб-приложения.
Предположим, что у нас имеется функция под названием 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;
}
}
Облачные серверы от Маклауд идеально подходят для разработки на TypeScript.
Зарегистрируйтесь по ссылке выше или кликнув на баннер и получите 10% скидку на первый месяц аренды сервера любой конфигурации!