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

Я имею в виду, что Typescript имеет несколько источников «несостоятельности» с такими функциями, как any, unknown, type assertionsetc. Необходимо понимать, как типы работают внутри, чтобы использовать их эффективно.

В этом блоге я расскажу о перечислениях, утверждениях const и типах объединения, а также о концепциях, лежащих в основе каждого из них.

Перечисления

В общем случае перечисления могут быть двух типов: строковые и числовые.

Перечисления строк

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

enum Direction {
   Up = "UP",
   Down = "DOWN"
}

Это генерирует следующий код во время выполнения в Javascript

var Direction;
(function (Direction) {
Direction["Up"] = "UP";
Direction["Down"] = "DOWN";
})(Direction || (Direction = {}));

Давайте посмотрим на пример, чтобы понять его использование:

Здесь processDirection принимает аргумент direction типа Direction с двумя возможными значениями: Direction.Up и Direction.Down и возвращает соответствующую функцию на основе значения.

Теперь, если вы хотите добавить новое свойство Left в Direction и если вы сделаете Direction.Left = "LEFT", Typescript выдаст ошибку Cannot assign to 'Left' because it is a read-only property.

Однако вы можете расширить тип Direction, переназначив его значение с помощью:

enum Direction {
    Left = "LEFT"
}

Теперь перечисление будет иметь ключи Up, Down и Left.

Еще одна важная вещь, на которую следует обратить внимание: перечисления строк соответствуют номинальной типизации, что означает, что если вы вызываете функцию processDirection со значением UP напрямую, она выдаст ошибку Argumet of type “UP" is not assignable to parameter of type ‘Direction’.

processDirection("UP") // Type error

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

Структурная и именная типизация

Структурная типизация означает, что меня волнует только форма шрифта. Рассмотрим этот пример:

Это совершенно безопасно для типов в мире Typescript из-за структурной типизации. Это означает, что функция normalize принимает значение типа Vector3D и передается в функцию calculateLength, аргумент которой имеет тип Vector2D. Хотя типы различаются, Typescript проверяет только форму объекта. То есть, получает ли calculateLength объект с ключами x: number и y: number. Неважно, является ли объект типом Vector2D или Vector3D.

В то время как номинальная типизация противоположна, что означает «обеспечение того, чтобы типы также были одинаковыми». В примере перечисления Typescript ожидает значение точного типа Direction вместо того, чтобы просто проверять его значение UP.

Числовые перечисления

Числовые перечисления могут быть с инициализированными значениями и без них.

Давайте сначала посмотрим на пример без инициализированных значений:

enum FileMode {
   Read,
   Write,
   ReadWrite
}

Это генерирует следующий код:

var FileMode;
(function (FileMode) {
    FileMode[FileMode["Read"] = 0] = "Read";
    FileMode[FileMode["Write"] = 1] = "Write";
    FileMode[FileMode["ReadWrite"] = 2] = "ReadWrite";
})(FileMode || (FileMode = {}));

Если вы попытаетесь вывести значение FileMode на консоль, оно будет

Числовое перечисление компилируется в объект, в котором хранятся сопоставления key -> value и value -> key. То есть вы можете получить доступ к значению либо с помощью FileMode.Read, либо с помощью FileMode[0]. Итак, FileMode.Read дает значение 0, а FileMode[0] дает значение Read. Перечисления строк не имеют этой функции обратного сопоставления.

Числовые перечисления также имеют функцию автоматического увеличения.

enum FileMode {
  Read = 1,
  Write,
  ReadWrite
}

Это производит вывод

(function (FileMode) {
    FileMode[FileMode["Read"] = 1] = "Read";
    FileMode[FileMode["Write"] = 2] = "Write";
    FileMode[FileMode["ReadWrite"] = 3] = "ReadWrite";
})(FileMode || (FileMode = {}));

Таким образом, если Read инициализирован как 1, значения Write и ReadWrite автоматически присваиваются 2 и 3 соответственно.

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

enum ShirtSize {
  XS = 28,
  S = 30,
  M = 32,
  L = 34
}

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

Хотя перечисления обладают множеством возможностей, они также имеют некоторые подводные камни. Например, числовые перечисления не являются типобезопасными.

function calculatePrice(size: ShirtSize, basePrice: number) {
     const priceMultiplierMap = {
        [ShirtSize.XS]: 1,
        [ShirtSize.S]: 1.5,
        [ShirtSize.M]: 2.1,
        [ShirtSize.L]: 2.5
     }
     return basePrice * priceMultiplierMap[size];
}
calculatePrice(ShirtSize.M, 35.5) 
// This is type safe and produces correct output as 74.55

Однако давайте рассмотрим другой вызов функции:

calculatePrice(20, 44)

В идеале, в этом случае Typescript должен выдавать ошибку Argument of type "20" is not assignable to parameter of type "ShirtSize". Но это не так, и во время выполнения приведенный выше вызов функции возвращает NaN. Мы раньше использовали Typescript, чтобы отлавливать подобные ошибки. Но числовые перечисления полностью разрушают уверенность, которую предлагает Typescript. Если вы хотите иметь константы с числами, используйте утверждения const.

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

Чтобы избежать этой проблемы, в Typescript есть нечто под названием const enums, которое мы увидим в следующем разделе.

Перечисления констант

Константные перечисления не имеют сгенерированного кода во время выполнения в JavaScript.

const enum Directions {
   Up = "UP",
   Down = "DOWN"
}
const currentDirection = Direction.Up;

Приведенный выше код удаляет все объявления перечисления и производит только следующий вывод во время выполнения.

const currentDirection = "UP" /* Up */;

Этот подход работает, если перечисление и его использование находятся в одном модуле. Однако, когда объявление перечисления и currentDirection находятся в отдельных файлах, необходимо импортировать перечисление в модуль, имеющий currentDirection. Теперь транспилятор должен прочитать оба модуля, чтобы подставить значение для Direction.Up.

Однако транспилеры, включая Babel, работают с одним файлом за раз, т. Е.

  • Компилятор читает модуль
  • Информация о типе модуля удалена.
  • Остальное записывается как файл JavaScript.

Этот процесс не читает импортированные модули. Следовательно, при таком подходе замена значения currentDirection на UP невозможна. Функция Typescript transpileModule и babel-plugin-transform-typescript babel следует этому подходу для компиляции файлов Typescript.

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

Константные утверждения

В Typescript есть альтернатива под названием const assertions, которая решает некоторые из проблем, описанных выше.

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

Расширяющиеся литеральные типы

Следующее объявление при наведении курсора на вашу среду IDE показывает всплывающую подсказку с информацией о типе как let fileName: string. Это означает, что тип fileName расширяется до типа string, поскольку переменная, инициализированная с помощью let, может быть изменена в любое время. Машинопись достаточно умен, чтобы вывести тип как string при объявлении с let.

let fileName = "const-assertions.md";

Это,

let fileName = "const-assertions.md";
fileName = "typescript.md"; // No type error
fileName = 15;
// Type error - Type 'number' is not assignable to type 'string'

Это также относится к strings, number, boolean.

let isAuthenticated = false; // Type - boolean
let itemsInCart = 12; // Type - number

Однако, если у вас есть

const userName = "Aishwarya"

Во всплывающей подсказке тип для userName будет отображаться как Aishwarya вместо string. Вы можете просмотреть это на игровой площадке Typescript здесь, чтобы лучше понять.

Это связано с тем, что переменная const может быть назначена только один раз, и, следовательно, значение Aishwarya никогда не может быть изменено снова ни в одной части кода. Итак, Typescript вывел тип как Aishwarya.

Нерасширяющиеся литеральные типы

В приведенном ниже коде тип newCurrentDay - string, а тип currentDay - Sunday.

const currentDay = "Sunday"; // Type : "Sunday"
let newCurrentDay = currentDay; // Type: string

Теперь, если мы используем константное утверждение as const для переменной const и присвоим его let переменной newCurrentDay, то его нельзя будет изменить, и возникнет ошибка типа, как указано ниже.

const currentDay = "Sunday" as const;
let newCurrentDay = currentDay; // Type: Sunday
newCurrentDay = "Monday"; // Error: Type '"Monday"' is not assignable to type '"Sunday"'.

Эти типы называются нерасширяющимися. Здесь тип newCurrentDay не расширяется до общего типа string, вместо этого он назначается Sunday.

Рассмотрим еще один пример с массивами:

const HEADING_LEVEL_1 = "h1" as const;
const HEADING_LEVEL_2 = "h2" as const;
const allowedLevels = [HEADING_LEVEL_1, HEADING_LEVEL_2]; 
// Type: ("h1" | "h2")[]
allowedLevels.push("h3") 
// Error: Argument of type '"h3"' is not assignable to parameter of type '"h1" | "h2"'

Если удалить as const в конце HEADING_LEVEL_1 и HEADING_LEVEL_2, тип будет string[]. В случае массивов вместо расширенного типа string у нас есть два нерасширенных типа h1 и h2.

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

Рассмотрим этот константный объект:

const Direction = {
   Up: "UP",
   Down: "DOWN"
}

Если вы проверите тип указанной выше переменной Direction, это будет

const Direction = {
   Up: string;
   Down: string;
}

Здесь тип Up и Down - string. Это потому, что мы можем изменить значение этих ключей в любой точке кода, как показано ниже.

Direction.Up = "New Up"
Direction.Down = "New Down"

Теперь давайте попробуем выполнить следующий код

processDirection выдает ошибку, как указано в комментарии рядом с ним. Чтобы решить эту проблему, мы могли бы сделать следующее.

const Direction = {
   Up: "UP" as "UP",
   Down: "DOWN" as "DOWN"
}

Теперь, если вы проверите тип Direction, он будет

// Type
const Direction = {
  Up: "UP",
  Down: "DOWN"
}

Теперь вызов функции processDirection с Direction.Up не приводит к ошибке типа.

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

const Direction = {
   Up: "UP",
   Down: "DOWN"
} as const

А теперь, если вы проверите тип приведенного выше кода

const Direction: {
  readonly Up: "UP";
  readonly Down: "DOWN";
}

Это означает, что литералы объектов получают readonly свойств, тогда как литералы массивов получают readonly кортежи.

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

type DirectionType = typeof Direction[keyof typeof Direction]

Теперь, если вы проверите приведенный выше код, он даст

type DirectionType = "UP" | "DOWN"

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

То есть, если вы хотите извлечь значения объекта без использования Object.values, вы должны сделать что-то вроде

Object.keys(Direction).map((key) => Direction[key]) 
// Outputs ["UP" | "DOWN" ]

Тип DirectionType делает то же самое. Здесь typeof Direction очень похож на реальный объект, и я извлекаю все ключи, используя ключевое слово keyof, и получаю все значения путем индексации DirectionType[keyof DirectionType] = ›Direction[<!-- keys of Direction ->]

Это можно упростить следующим образом:

type Values<ObjectLiteral> = ObjectLiteral[keyof ObjectLiteral];
const Direction = {
   Up: "UP",
   Down: "DOWN"
} as const;
type DirectionType = Values<typeof Direction>;
function processDirection(direction: DirectionType) {
    /**  Code */
   return callbacksMap[direction];
}

Я создал общий тип Values, который принимает в качестве аргумента буквальный тип объекта ObjectLiteral. Этот тип извлекает значения ключей из ObjectLiteral - `typeof Direction`: в нашем случае это «ВВЕРХ» и «ВНИЗ».

В приложении будет много таких константных утверждений, и мы не хотим повторять эту строку для каждого утверждения (после DRY), и теперь DirectionType просто с typeof Direction.

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

Давайте попробуем переписать ShirtSize пример, который мы видели выше, с помощью утверждения const.

В этом примере передача 20 в size приводит к ошибке, как и ожидалось, и является типобезопасной. У этих константных утверждений нет функции автоматической инициализации, которую предлагает числовое перечисление.

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

Но нам нужно включить дополнительную строку кода для типа, например: type DirectionType = Values<typeof Direction>. На мой взгляд, добавить эту одну строку кода не проблема, учитывая преимущества уменьшения размера файла и главное: безопасность типов с числами.

Типы союзов

Еще одно возможное решение для обработки таких сценариев - это типы объединения.

type Direction = "UP" | "DOWN";
function processDirection(direction: Direction) {
   /**  Code */
   return callbacksMap[direction];
}
processDirection("UP");
// For numbers
type ShirtSize = 28 | 30 | 32 | 34;

Этот подход является типобезопасным, не имеет значений, генерируемых во время выполнения, не имеет шаблонного кода и прост. Но одна проблема с этим подходом заключается в том, что магические строки и числа будут распределены по всему коду.

Вывод

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

Я бы полностью избегал использования числовых перечислений, потому что они небезопасны по типу. Целью дополнительной сложности использования Typescript является уверенность, которую он обеспечивает с его типовой безопасностью. Если основная цель недействительна, то нет смысла ее использовать. Если вас беспокоит размер файла и код, сгенерированный во время выполнения в интерфейсных проектах из-за перечислений строк, вы можете рассмотреть константные утверждения или типы объединения. Но если это проект узла, вы можете безопасно использовать перечисления строк, поскольку размер файла не имеет большого значения, и вы можете сохранить некоторый шаблон с утверждениями const или волшебными строками из типов union.

Но самое главное - последовательность. Если вы решили следовать подходу, придерживайтесь его и будьте последовательны на протяжении всего проекта.

Последнее замечание, прежде чем я закончу, заключается в том, что когда в 2012 году был представлен Typescript, они представили множество функций времени выполнения, которых не было в Javascript. Но со временем TC39, который управляет Javascript, добавил в Javascript многие из тех же функций. И функции, которые они добавили, не соответствовали тому, что уже было в Typescript. Это оставило команду Typescript в неприятной ситуации. Итак, они сформулировали свою цель дизайна, которая гласит: TC39 определяет среду выполнения, тогда как Typescript улучшает ее качество в системе типов.

Итак, когда вы решите использовать функцию в Typescript, которой нет в Javascript, существует вероятность, что TC39 может представить ее с другим синтаксисом. Многие функции, которые сейчас присутствуют в Javascript, включая классы, необязательную цепочку, объединение с нулевым значением, были заимствованы из Typescript. Хороший блог про то.