Spec-Zone.ru › TypeScript 5.1

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

Представьте, что у нас есть функция, названная padLeft.

function padLeft(padding: number | string, input: string): string {
  throw new Error("Not implemented yet!");
}

Если padding является number, она будет интерпретироваться как количество пробелов, которые нужно добавить перед input. Если padding является string, то нужно просто добавить padding перед input. Давайте попробуем реализовать логику, когда padLeft получает number в качестве padding.

function padLeft(padding: number | string, input: string) {
  return " ".repeat(padding) + input;
}

О-хо-хо, у нас ошибка в padding. TypeScript предупреждает нас, что мы передаём значение типа number | string функции repeat, которая принимает только number, и это верно. Другими словами, мы не проверили явно, является ли padding number в первую очередь, и мы не обрабатываем случай, когда это string, поэтому давайте сделаем именно это.

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

Если код в основном выглядит как обычный JavaScript-код, то это и есть цель. Помимо аннотаций, этот TypeScript-код выглядит как JavaScript. Идея в том, что система типов TypeScript стремится сделать как можно проще написание типичного JavaScript-кода без лишних усилий для обеспечения типовой безопасности.

Хотя это может показаться не очень сложным, на самом деле здесь происходит много. Подобно тому, как TypeScript анализирует значения во время выполнения с помощью статических типов, он накладывает анализ типов на конструкции управления потоком выполнения JavaScript, такие как if/else, условные выражения, циклы, проверки истинности и т. д., которые могут влиять на эти типы.

Внутри нашей проверки if TypeScript видит typeof padding === "number" и понимает это как специальную форму кода, называемую фильтром типов. TypeScript отслеживает возможные пути выполнения наших программ для анализа самого специфичного возможного типа значения в данной позиции. Он рассматривает эти специальные проверки (называемые фильтрами типов) и присваивания, а процесс уточнения типов до более специфичных типов, чем объявленные, называется сужением типов. Во многих редакторах мы можем наблюдать, как эти типы изменяются, и мы даже будем делать это в наших примерах.

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

Существует несколько различных конструкций, которые TypeScript понимает для сужения типов.

typeof фильтры типов

Как мы видели, JavaScript поддерживает оператор typeof, который может предоставить очень базовую информацию о типе значений во время выполнения. TypeScript ожидает, что он вернёт определённый набор строк:

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

Как мы видели с padLeft, этот оператор довольно часто встречается во многих JavaScript-библиотеках, и TypeScript может его использовать для сужения типов в различных ветвях.

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

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  } else {
    // do nothing
  }
}

В функции printAll мы пытаемся проверить, является ли strs объектом, чтобы определить, является ли это массивным типом (сейчас, возможно, стоит напомнить, что массивы являются объектами в JavaScript). Но оказывается, что в JavaScript typeof null на самом деле "object"! Это одна из несчастных случайностей истории.

Опытным пользователям это может не удивить, но не все сталкивались с этим в JavaScript; к счастью, TypeScript сообщает нам, что strs был сужен только до string[] | null вместо просто string[].

Это может стать хорошим переходом к тому, что мы назовём проверкой «истинности».

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

Истинность — возможно, не слово, которое вы найдёте в словаре, но вы наверняка о нём услышите в JavaScript.

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

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}

В JavaScript конструкции, такие как if сначала «преобразуют» свои условия в boolean для их интерпретации, а затем выбирают свои ветви в зависимости от того, является ли результат true или false. Такие значения, как

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

все преобразуются в false, а другие значения преобразуются в true. Вы всегда можете привести значения к boolean с помощью функции Boolean, или используя сокращённую двойную логическую инверсию. (Последнее имеет преимущество, что TypeScript выводит узкий литеральный булевский тип true, в то время как первое — как тип boolean.)

// both of these result in 'true'
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true,    value: true

Эта особенность довольно часто используется, особенно для защиты от значений, таких как 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

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

function printAll(strs: string | string[] | null) {
  // !!!!!!!!!!!!!!!!
  //  DON'T DO THIS!
  //   KEEP READING
  // !!!!!!!!!!!!!!!!
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

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

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

Ещё один важный момент о сужении по истинности: логические отрицания с ! отфильтровывают ветви с отрицаниями.

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

Сужение по равенству

TypeScript также использует switch операторы и проверки на равенство, такие как ===, !==, ==, и !=, для сужения типов. Например:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // We can now call any 'string' method on 'x' or 'y'.
    x.toUpperCase();
    y.toLowerCase();
  } else {
    console.log(x);
    console.log(y);
  }
}

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

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

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

Более слабые проверки равенства JavaScript с == и != также корректно сужаются. Если вы не знакомы, проверка, является ли что-то == null в действительности, проверяет не только то, является ли оно конкретно значением null, но также и то, является ли оно потенциально undefined. То же самое относится к == undefined: оно проверяет, является ли значение либо null, либо undefined.

interface Container {
  value: number | null | undefined;
}
 
function multiplyValue(container: Container, factor: number) {
  // Remove both 'null' and 'undefined' from the type.
  if (container.value != null) {
    console.log(container.value);
 
    // Now we can safely multiply 'container.value'.
    container.value *= factor;
  }
}

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

JavaScript имеет оператор для определения наличия свойства у объекта или в его цепочке прототипов: оператор in. TypeScript учитывает это как способ сужения потенциальных типов.

Например, с кодом: "value" in x, где "value" — строковый литерал, а x — союзный тип. Ветвь «true» сужает типы x, у которых есть либо необязательное, либо обязательное свойство value, а ветвь «false» сужает до типов, у которых свойство value необязательное или отсутствует.

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    return animal.swim();
  }
 
  return animal.fly();
}

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

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;
  } else {
    animal;
  }
}

instanceof сужение типов

JavaScript имеет оператор для проверки, является ли значение «экземпляром» другого значения. Более конкретно, в JavaScript x instanceof Foo проверяет, содержит ли цепочка прототипов x Foo.prototype. Хотя мы не будем углубляться в эту тему, и вы увидите больше примерах, когда дойдём до классов, они всё ещё могут быть полезны для большинства значений, которые могут быть созданы с помощью new. Как вы могли догадаться, instanceof также является фильтром типов, и TypeScript сужает типы в ветвях, защищённых instanceof.

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

Присваивания

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

let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1;
 
console.log(x);
x = "goodbye!";
 
console.log(x);

Обратите внимание, что каждое из этих присваиваний допустимо. Несмотря на то, что наблюдаемый тип x изменился на number после первого присваивания, мы всё ещё могли присвоить string переменной x. Это потому, что объявленный тип x — тип, с которым x начинал — это string | number, а возможность присваивания всегда проверяется относительно объявленного типа.

Если бы мы присвоили boolean переменной x, мы бы увидели ошибку, так как это не входило в объявленный тип.

let x = Math.random() < 0.5 ? 10 : "hello world!";
x = 1;
 
console.log(x);
x = true;
 
console.log(x);

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

До этого момента мы рассмотрели несколько базовых примеров того, как TypeScript сужает типы внутри конкретных ветвей. Но происходит немного больше, чем просто проход по каждой переменной и поиск стражей типов в ifs, whiles, условных операторах и т. д. Например,

function padLeft(padding: number | string, input: string) {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}

padLeft возвращает значение внутри своего первого if блока. TypeScript смог проанализировать этот код и увидеть, что остальная часть тела (return padding + input;) является недостижимой в случае, когда padding является number. В результате он смог удалить number из типа padding (сужение от string | number до string ) для остальной части функции.

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

function example() {
  let x: string | number | boolean;
 
  x = Math.random() < 0.5;
 
  console.log(x);
 
  if (Math.random() < 0.5) {
    x = "hello";
    console.log(x);
  } else {
    x = 100;
    console.log(x);
  }
 
  return x;
}

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

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

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

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

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

Каждый раз, когда isFish вызывается с какой-либо переменной, TypeScript сужает эту переменную до этого конкретного типа, если исходный тип совместим.

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
 
if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

Обратите внимание, что TypeScript не только знает, что pet является Fish в if ветви; он также знает, что в else ветви у вас нет Fish, поэтому у вас должен быть Bird.

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

const zoo: (Fish | Bird)[] = [getSmallPet(), getSmallPet(), getSmallPet()];
const underWater1: Fish[] = zoo.filter(isFish);
// or, equivalently
const underWater2: Fish[] = zoo.filter(isFish) as Fish[];
 
// The predicate may need repeating for more complex examples
const underWater3: Fish[] = zoo.filter((pet): pet is Fish => {
  if (pet.name === "sharkey") return false;
  return isFish(pet);
});

Кроме того, классы могут использовать this is Type для сужения своего типа.

Функции утверждений

Типы также могут быть сужены с помощью Функций утверждений.

Дискриминированные союзы

Большинство примеров, которые мы рассматривали до сих пор, были сосредоточены на сужении одиночных переменных с простыми типами, такими как string, boolean и number. Хотя это распространённо, в большинстве случаев в JavaScript мы будем иметь дело с несколько более сложными структурами.

Для мотивации давайте представим, что мы пытаемся закодировать фигуры, такие как круги и квадраты. Круги отслеживают свои радиусы, а квадраты — свои длины сторон. Мы будем использовать поле, называемое kind, чтобы указать, с какой формой мы имеем дело. Вот первая попытка определения Shape.

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

Обратите внимание, что мы используем объединение типов строковых литералов: "circle" и "square" для указания того, следует ли рассматривать форму как круг или квадрат соответственно. Используя "circle" | "square" вместо string, мы можем избежать проблем с неправильным написанием.

function handleShape(shape: Shape) {
  // oops!
  if (shape.kind === "rect") {
    // ...
  }
}

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

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}

При strictNullChecks это даёт нам ошибку — что правильно, так как radius может быть не определено. Но что, если мы выполним соответствующие проверки по свойству kind?

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

Хмм, TypeScript по-прежнему не знает, что делать здесь. Мы достигли точки, где мы знаем больше о наших значениях, чем проверяющий тип. Мы могли бы попробовать использовать утверждение о непустом значении (! после shape.radius ), чтобы сказать, что radius определённо присутствует.

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

Но это не идеально. Мы немного накричали на проверяющий тип с этими утверждениями о непустых значениях (! ), чтобы убедить его в том, что shape.radius было определено, но эти утверждения подвержены ошибкам, если мы начнём перемещать код. Кроме того, за пределами strictNullChecks мы можем случайно получить доступ к любому из этих полей (поскольку необязательные свойства просто предполагаются всегда присутствующими при их чтении). Мы определённо можем сделать лучше.

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

function getArea(shape: Shape) {
  return Math.PI * shape.radius ** 2;
}

Как и в нашем первом определении Shape, это всё ещё ошибка. Когда radius было необязательным, у нас была ошибка (с включённым strictNullChecks), потому что TypeScript не мог определить, присутствует ли свойство. Теперь, когда Shape является объединением, TypeScript говорит нам, что shape может быть Square, и Square не имеют radius , определённого для них! Оба толкования верны, но только кодирование объединением Shape вызовет ошибку независимо от того, как настроен strictNullChecks.

Но что, если мы попробуем снова проверить свойство kind?

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

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

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

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

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
  }
}

Важным моментом здесь было кодирование Shape. Сообщение правильной информации TypeScript — что Circle и Square были действительно двумя отдельными типами со специфическими полями kind — было решающим. Это позволяет нам написать безопасный для типов код TypeScript, который ничем не отличается от JavaScript-кода, который мы написали бы иначе. После этого система типов смогла сделать «правильное» действие и выяснить типы в каждой ветке нашего оператора switch.

Кстати, попробуйте поэкспериментировать с вышеприведённым примером и удалите некоторые из операторов return. Вы увидите, что проверка типов может помочь избежать ошибок, когда вы случайно переходите через разные пункты в операторе switch.

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

Тип never

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

Проверка полноты

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

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

type Shape = Circle | Square;
 
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 вызовет ошибку TypeScript:

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;
      return _exhaustiveCheck;
  }
}

© 2012-2023 Microsoft
Licensed under the Apache License, Version 2.0.
https://www.typescriptlang.org/docs/handbook/2/narrowing.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API