Статьи

10 основных советов и трюков с TypeScript для разработчиков угловых приложений

В этой статье мы рассмотрим ряд советов и приемов, которые могут пригодиться в каждом проекте Angular и за его пределами при работе с TypeScript.

В последние годы потребность в статической типизации в JavaScript быстро возросла. Растущие фронтальные проекты, более сложные сервисы и сложные утилиты командной строки усилили необходимость более оборонительного программирования в мире JavaScript. Кроме того, бремя компиляции приложения перед его фактическим исполнением не рассматривается как слабость, а скорее как возможность. Хотя появились две сильные стороны (TypeScript и Flow), многие тренды фактически указывают на то, что может преобладать только одна — TypeScript.

Помимо маркетинговых заявлений и общеизвестных свойств, в TypeScript есть удивительное сообщество с очень активными участниками. За ним также стоит одна из лучших команд в плане языкового дизайна. Во главе с Андерсом Хейлсбергом команда смогла полностью трансформировать ландшафт крупномасштабных проектов JavaScript, превратившись в бизнес исключительно на основе TypeScript. С очень успешными проектами, такими как VSTS или Visual Studio Code, сами Microsoft твердо верят в эту технологию.

Но это не только возможности TypeScript, которые делают язык привлекательным, но также возможности и рамки, которые обеспечивает TypeScript. Решение Google полностью использовать TypeScript в качестве своего предпочтительного языка для Angular 2+ оказалось беспроигрышным. TypeScript привлек не только больше внимания, но и сам Angular. Используя статическую типизацию, компилятор уже может дать нам информативные предупреждения и полезные объяснения того, почему наш код не будет работать.

Совет по TypeScript 1. Предоставьте свои собственные определения модуля

TypeScript — это расширенный набор JavaScript. Таким образом, можно использовать любой существующий пакет npm. Хотя экосистема TypeScript огромна, еще не все библиотеки поставляются с соответствующими типами. Хуже того, для некоторых (меньших) пакетов даже не существует отдельных объявлений (в виде @types/{package} ). На данный момент у нас есть два варианта:

  1. ввести унаследованный код, используя совет TypeScript
  2. Определите API модуля самостоятельно.

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

Какой самый простой способ предоставить наши собственные определения модулей? Просто создайте module.d.ts в исходном каталоге (или он может также называться как пакет — например, unknown-module.d.ts для пакета npm unknown-module ).

Давайте предоставим пример определения для этого модуля:

 declare module 'unknown-module' { const unknownModule: any; export = unknownModule; } 

Очевидно, это только первый шаг, так как мы не должны его вообще использовать. (Для этого есть много причин. Совет 5 по TypeScript показывает, как этого избежать.) Однако достаточно рассказать TypeScript о модуле и предотвратить ошибки компиляции, такие как «неизвестный модуль« неизвестный модуль »». Здесь обозначение export предназначено для классических module.exports = ...

Вот потенциальное потребление в TypeScript такого модуля:

 import * as unknownModule from 'unknown-module'; 

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

 declare module 'unknown-module' { interface UnknownModuleFunction { (): void; } const unknownModule: UnknownModuleFunction; export = unknownModule; } 

Конечно, также возможно использовать пакеты, которые экспортируют функциональность, используя синтаксис модуля ES6:

 declare module 'unknown-module' { interface UnknownModuleFunction { (): void; } const unknownModule: UnknownModuleFunction; export const constantA: number; export const constantB: string; export default unknownModule; } 

Совет по TypeScript 2: Enum против Const Enum

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

 const Foo = { A: 1, B: 2, }; 

и

 enum Foo { A = 1, B = 2, } 

имеет не только синтаксическую природу в TypeScript. Хотя оба будут скомпилированы в объект (т. Е. Первый из них останется без изменений, а последний будет преобразован с помощью TypeScript), enum TypeScript защищено и содержит только постоянные члены. Таким образом, невозможно определить его значения во время выполнения. Кроме того, изменения этих значений не будут разрешены компилятором TypeScript.

Это также отражено в подписи. Последний имеет постоянную подпись, которая похожа на

 interface EnumFoo { A: 1; B: 2; } 

в то время как объект обобщен:

 interface ConstFoo { A: number; B: number; } 

Таким образом, мы не увидим значения этих «констант» в нашей IDE. Что теперь дает нам const enum ? Сначала давайте посмотрим на синтаксис:

 const enum Foo { A = 1, B = 2, } 

Это на самом деле то же самое — но заметьте, впереди есть const . Это маленькое ключевое слово имеет огромное значение. Почему? Потому что в этих условиях TypeScript ничего не скомпилирует. Итак, у нас есть следующий каскад:

  • объекты не затрагиваются, но генерируют неявное обобщенное объявление формы (интерфейс)
  • enum сгенерирует некоторый шаблонный объект-инициализатор вместе со специализированным объявлением формы
  • const enum не генерирует ничего кроме специализированного объявления формы.

Теперь, как последний тогда используется в коде? Простыми заменами. Рассмотрим этот код:

 enum Foo { A = 1, B = 2 } const enum Bar { A = 1, B = 2 } console.log(Bar.A, Foo.B); 

Здесь мы в конечном итоге в JavaScript со следующим результатом:

 var Foo; (function (Foo) { Foo[Foo["A"] = 1] = "A"; Foo[Foo["B"] = 2] = "B"; })(Foo || (Foo = {})); console.log(1 /* A */, Foo.B); 

Обратите внимание, что только 5 строк были сгенерированы для enum Foo , тогда как enum Bar приводил только к простой замене (постоянная инъекция) Таким образом, const enum является функцией только времени компиляции, в то время как оригинальный enum является функцией времени исполнения + время компиляции. Большинство проектов хорошо подходят для const enum , но могут быть случаи, когда enum предпочтительнее.

Совет по TypeScript 3: выражения типа

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

 interface StatusResponse { issues: Array<string>; status: 'healthy' | 'unhealthy'; } 

Запись в 'healthy' | 'unhealthy' 'healthy' | 'unhealthy' означает либо healthy постоянную строку, либо другую постоянную строку, равную unhealthy . Хорошо, это определение звука интерфейса. Однако, теперь у нас также есть метод в нашем коде, который хочет изменить объект типа StatusResponse :

 function setHealthStatus(state: 'healthy' | 'unhealthy') { // ... } 

Пока все хорошо, но изменив это сейчас на 'healthy' | 'unhealthy' | 'unknown' 'healthy' | 'unhealthy' | 'unknown' 'healthy' | 'unhealthy' | 'unknown' приводит к двум изменениям (одно в определении интерфейса и одно в определении типа аргумента в функции). Не круто. Фактически, выражения, которые мы рассматривали до сих пор, уже являются выражениями типа, мы просто не «храним» их, то есть присваиваем им имя (иногда называемое псевдонимом ). Давайте сделаем это:

 type StatusResponseStatus = 'healthy' | 'unhealthy'; 

В то время как const , var и let создают объекты во время выполнения из выражений JS, type создает объявление типа во время компиляции из выражений TS (так называемые выражения типов). Эти объявления типов могут затем использоваться:

 interface StatusResponse { issues: Array<string>; status: StatusResponseStatus; } 

С такими псевдонимами в нашем инструментальном поясе мы можем легко реорганизовать систему типов по желанию. Использование отличного вывода типа TypeScript просто распространяет изменения соответствующим образом.

Совет по TypeScript 4. Использование дискриминаторов

Одним из применений выражений типов является ранее введенное объединение нескольких (простых) выражений типов — то есть имен типов или констант. Конечно, объединение не ограничивается выражениями простого типа, но для удобства чтения мы не должны придумывать такие структуры, как эта:

 type MyUnion = { a: boolean, b: number, } | { c: number, d: { sub: string, } } | { (): void; }; 

Вместо этого нам нужно простое и понятное выражение, такое как это:

 type MyUnion = TypeA | TypeB | TypeC; 

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

 interface Line { points: 2; // other members, eg, from, to, ... } interface Triangle { points: 3; // other members, eg, center, width, height } interface Rectangle { points: 4; // other members, eg, top, right, bottom, left } 

Дискриминационный союз между этими типами может быть следующим:

 type Shape = Line | Triangle | Rectangle; 

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

 function calcArea(shape: Shape) { switch (shape.points) { case 2: // ... incl. return case 3: // ... incl. return case 4: // ... incl. return default: return Math.NaN; } } 

Естественно, операторы switch весьма полезны для этой задачи, но можно использовать и другие средства проверки.

Дискриминационные объединения пригодятся во всех видах сценариев — например, при обходе AST-подобной структуры при работе с файлами JSON, которые имеют похожий механизм ветвления в своей схеме.

Совет по TypeScript 5: избегайте всего, если это не так

Мы все были там: мы точно знаем, какой код писать, но мы не можем удовлетворить компилятор TypeScript, чтобы принять нашу модель данных для кода. Что ж, к счастью для нас, мы всегда можем прибегнуть к any спасению дня. Но мы не должны. any должен использоваться только для типов, которые на самом деле могут быть любыми. (Например, специально JSON.parse возвращает any , так как результатом может быть что угодно в зависимости от анализируемой строки.)

Например, в одном из наших хранилищ данных мы явно определили, что определенное поле custom будет содержать данные типа any . Мы не знаем, что там будет установлено, но потребитель может выбирать данные (и, следовательно, тип данных). Мы не хотели и не могли помешать этому, поэтому тип any был по-настоящему.

Однако в большинстве сценариев (то есть во всех сценариях, которые покрыты исключительно нашим кодом) any тип обычно имеет один или несколько типов. Нам нужно только выяснить, какой именно тип мы ожидаем и как создать такой тип, чтобы предоставить TypeScript всю необходимую информацию.

Используя некоторые из предыдущих советов — например, совет TypeScript 4 и совет TypeScript 3 — мы уже можем решить некоторые из самых больших проблем:

 function squareValue(x: any) { return Math.pow(x * 1, 2); } 

Мы бы предпочли максимально ограничить ввод:

 function squareValue(x: string | number) { return Math.pow(+x, 2); } 

Теперь интересная часть состоит в том, что первое выражение x * 1 допускается с any , но вообще запрещено. Тем не менее, +x дает нам принудительное приведение к number по желанию. Чтобы проверить, работает ли наш состав с данными типами, нам нужно быть конкретным. Вопрос «какие типы можно вводить здесь?» Является законным, на который мы должны ответить, прежде чем TypeScript сможет предоставить нам полезную информацию.

Совет по TypeScript 6. Эффективное использование обобщений

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

Давайте рассмотрим следующий случай классической вспомогательной функции JS:

 function getOrUpdateFromCache(key, cb) { const value = getFromCache(key); if (value === undefined) { const newValue = cb(); setInCache(key, newValue); return newValue; } return value; } 

Перевод этого непосредственно в TypeScript оставляет нас с двумя any s: один — это данные, полученные из обратного вызова, а другой — от самой функции. Однако, это не должно выглядеть так, поскольку мы, очевидно, знаем тип (мы передаем в cb ):

 function getOrUpdateFromCache<T>(key: string, cb: () => T) { const value: T = getFromCache(key); if (value === undefined) { const newValue = cb(); setInCache(key, newValue); return newValue; } return value; } 

Единственная проблемная позиция в приведенном выше коде — это явное присвоение типа результату вызова функции getFromCache . Здесь мы должны доверять нашему коду на данный момент, чтобы последовательно использовать одни и те же типы для одних и тех же ключей. В совете по TypeScript 10 мы узнаем, как улучшить эту ситуацию.

В большинстве случаев универсальные шаблоны используются только для «прохождения» типа, то есть для обучения TypeScript относительно связи между определенными типами аргументов (в первом случае тип результата связан с возвращаемым типом обратного вызова). ). Обучение TypeScript о таких отношениях также может быть предметом дальнейших ограничений, которые затем устанавливаются TypeScript.

Хотя универсальные шаблоны просты в использовании вместе с интерфейсами, типами, классами и стандартными функциями, они могут показаться не такими доступными для функций со стрелками. Эти функции являются анонимными по определению (их необходимо назначить переменной, доступ к которой осуществляется через имя).

Как правило, мы можем придерживаться этого подхода: просто подумайте о нормальном, но анонимном объявлении функции. Здесь только имя ушло. Таким образом, <T> естественно помещается перед круглыми скобками. Мы заканчиваем с:

 const getOrUpdateFromCache = <T>(key: string, cb: () => T) => /* ...*/; 

Однако, как только мы введем это в файл TSX (по любой причине), мы получим ошибку ERROR: unclosed T tag . Это та же проблема, что и с приведениями (решается там с помощью оператора as ). Теперь наш обходной путь заключается в явном указании TypeScript, что синтаксис предназначен для использования обобщений:

 const getOrUpdateFromCache = <T extends {}>(key: string, cb: () => T) => /* ...*/; 

Совет по TypeScript 7: внесите устаревший код

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

Лучшая альтернатива — просто использовать allowJs в файле tsconfig.json , рядом с обычными (довольно сильными) параметрами:

 { "compilerOptions": { "allowJs": true, // ... } } 

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

Совет по TypeScript 8. Создание функций со свойствами

Мы уже знаем, что использование интерфейсов для объявления формы функции является разумным способом. Кроме того, этот подход позволяет нам прикрепить некоторые свойства к данному типу функции. Давайте сначала посмотрим, как это может выглядеть на практике:

 interface PluginLoader { (): void; version: string; } 

Определить это просто, но, к сожалению, работать с ним нет. Давайте попробуем использовать этот интерфейс по назначению, создав объект, который выполняет интерфейс:

 const pl: PluginLoader = () => {}; pl.version = '1.0.0'; 

Ой: мы не можем пройти декларацию. TypeScript (правильно) жалуется, что свойство version отсутствует. Хорошо, так как насчет следующего обходного пути:

 interface PluginLoaderLight { (): void; version?: string; } const pl: PluginLoaderLight = () => {}; pl.version = '1.0.0'; 

Отлично. Это работает, но у него есть один существенный недостаток: хотя мы знаем, что после присвоения pl.version свойство version всегда будет существовать в pl , TypeScript не знает этого. Таким образом, с ее точки зрения, любой доступ к version может быть неправильным и должен быть проверен на undefined первую очередь. Другими словами, в текущем решении интерфейс, который мы используем для создания объекта этого типа, должен отличаться от интерфейса, используемого для потребления. Это не идеально.

К счастью, есть способ обойти эту проблему. Вернемся к нашему оригинальному интерфейсу PluginLoader . Давайте попробуем это с приведением к TypeScript «Поверьте мне, я знаю, что я делаю».

 const pl = <PluginLoader>(() => {}); pl.version = '1.0.0'; 

Цель этого состоит в том, чтобы сказать TypeScript: «Посмотрите на эту функцию, я знаю, что она будет иметь данную форму ( PluginLoader )». TypeScript все еще проверяет, можно ли это все еще выполнить. Поскольку нет доступных конфликтующих определений, он примет это приведение. Касты должны быть нашей последней линией обороны. Я не рассматриваю any либо возможную линию защиты: либо тип является any для реального (всегда может быть — мы просто принимаем что-либо, совершенно нормально), либо он не должен использоваться и должен быть заменен чем-то конкретным (см. TypeScript совет 5).

Хотя способ литья может решить такие проблемы, как описанный, он может оказаться невозможным в некоторых неангулярных средах (например, в компонентах React). Здесь нам нужно выбрать альтернативный вариант приведения, а именно оператор as :

 const pl = (() => {}) as PluginLoader; pl.version = '1.0.0'; 

Лично я бы всегда ходил as заброшенный. Они не только работают всегда , они также вполне читабельны даже для тех, у кого нет фона TypeScript. Для меня непротиворечивость и читаемость — это два принципа, которые всегда должны быть в основе каждой кодовой базы. Они могут быть сломаны, но для этого должны быть веские причины.

Совет по TypeScript 9: ключ оператора

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

Рассмотрим следующий интерфейс:

 interface AbstractControllerMap { user: UserControllerBase; data: DataControllerBase; settings: SettingsControllerBase; //... } 

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

Очевидно, мы могли бы просто заявить, что функция может выглядеть так:

 function actOnAbstractController(controllerName: string) { // ... } 

Недостатком является то, что у нас определенно больше знаний, которыми мы не делимся с TypeScript. Поэтому лучшая версия будет такой:

 function actOnAbstractController(controllerName: 'user' | 'data' | 'settings') { // ... } 

Однако, как уже отмечалось в совете по TypeScript 3, мы хотим быть устойчивыми к рефакторингу. Это не устойчиво. Если мы добавим еще один ключ (то есть сопоставим другой контроллер в нашем примере выше), нам нужно будет редактировать код в нескольких местах.

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

 type ControllerNames = keyof AbstractControllerMap; 

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

 function actOnAbstractController(controllerName: ControllerNames) { // ... } 

keyof то, что keyof самом деле будет уважать слияние интерфейсов. Независимо от того, где мы разместим keyof , он всегда будет работать против «окончательной» версии типа, к keyof он применяется. Это также очень полезно, когда вы думаете о фабричных методах и эффективном дизайне интерфейса для них.

Совет по TypeScript 10: определения эффективного обратного вызова

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

 interface MyEventEmitter { on(eventName: string, cb: (e: any) => void): void; off(eventName: string, cb: (e: any) => void): void; emit(eventName: string, event: any): void; } 

Оглядываясь назад на все предыдущие приемы, мы знаем, что этот дизайн не идеален и не приемлем. Так что мы можем с этим поделать? Начнем с простого приближения к проблеме. Первым шагом, безусловно, является определение всех возможных имен событий. Мы могли бы использовать выражения типа, представленные в подсказке TypeScript 3, но еще лучше было бы отобразить объявления типов событий, как в предыдущем разделе.

Итак, мы начинаем с нашей карты и применяем подсказку TypeScript 9, чтобы получить следующее:

 interface AllEvents { click: any; hover: any; // ... } type AllEventNames = keyof AllEvents; 

Это уже имеет некоторый эффект. Предыдущее определение интерфейса теперь становится:

 interface MyEventEmitter { on(eventName: AllEventNames, cb: (e: any) => void): void; off(eventName: AllEventNames, cb: (e: any) => void): void; emit(eventName: AllEventNames, event: any): void; } 

Чуть лучше, но у нас еще есть any интересные позиции. Теперь можно применить совет 6 по TypeScript, чтобы сделать TypeScript немного более осведомленным о введенном eventName :

 interface MyEventEmitter { on<T extends AllEventNames>(eventName: T, cb: (e: any) => void): void; off<T extends AllEventNames>(eventName: T, cb: (e: any) => void): void; emit<T extends AllEventNames>(eventName: T, event: any): void; } 

Это хорошо, но не достаточно. Теперь TypeScript знает точный тип eventName когда мы его вводим, но мы не можем использовать информацию, хранящуюся в T ни для чего. За исключением того, что мы можем использовать его с другими мощными выражениями типов: операторы индекса, применяемые к интерфейсам.

 interface MyEventEmitter { on<T extends AllEventNames>(eventName: T, cb: (e: AllEvents[T]) => void): void; off<T extends AllEventNames>(eventName: T, cb: (e: AllEvents[T]) => void): void; emit<T extends AllEventNames>(eventName: T, event: AllEvents[T]): void; } 

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

 interface ClickEvent { leftButton: boolean; rightButton: boolean; } interface AllEvents { click: ClickEvent; // ... } 

Реальная мощная часть теперь в том, что объединение интерфейса все еще работает То есть мы можем расширить наши определения событий, используя то же имя интерфейса снова:

 interface AllEvents { custom: { field: string; }; } 

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

Дальнейшее чтение

Вывод

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

Какие уловки заставляют ваш код сиять? Где вы чувствуете себя наиболее комфортно в? Дайте нам знать об этом в комментариях!