Первая версия TypeScript увидела свет больше 7 лет назад. За это время язык повзрослел и, медленно, но верно становится стандартом JavaScript разработки. Slack, AirBnB, Lyft и многие другие компании выбирают TypeScript для разработки новых приложений как для браузера, так и NodeJS сервисов. Конечно, в любом подходе всегда есть свои плюсы и минусы. Один из минусов заключается в том, что многие NPM библиотеки всё ещё написаны на JavaScript. С такой проблемой столкнулись и мы во время миграции на TypeScript. Нам потребовалось написать объявления типов для нашей библиотеки UI компонентов. Мы хотели получить инструмент, который мог бы служить дополнительной документацией. Мы также хотели собрать в одном месте всё, что можно использовать при вызове того или иного метода нашего JS пакета. Расскажу, какие шаги мы выполнили и что получили в результате.
Объявления типов
Для любого JavaScript модуля мы можем создать файл с расширением .d.ts
, в котором легко описать интерфейс данного модуля. TypeScript анализатор будет использовать объявленные типы данных из .d.ts
файла каждый раз, когда мы делаем импорт соответствующего JS модуля. Такой подход позволяет наполнить проект контекстом во время миграции на TypeScript. На практике это выглядит достаточно тривиально, и пример ниже показывает суть подхода:
// sample.js
export const pageSize = 25;
export const pageSizes = [25, 50, 100];
export const getOffset = (page, pageSize) => page * pageSize;
Мы можем сделать импорт sample.js
в некотором TypeScript модуле. Но ни автодополнение, ни вывод типов — ничего из этого работать не будет. Каждому объекту из импорта будет присвоен тип any
, что не сильно поможет в дальнейшем. Для того чтобы описать, какой API представляет наш JavaScript модуль, мы пользуемся соглашением и создаём файл с объявлением типов:
// sample.d.ts
export const pageSize: number;
export const pageSizes: [number, number, number];
export const getOffset: (page: number, pageSize: number) => number;
Важный момент — TypeScript оперирует объявлениями типов, так как не может получить эту информацию из JavaScript кода. Если, к примеру, мы удалим export const pageSizes = [25, 50, 100] из модуля sample.js
, это никак не повлияет на поведение TypeScript анализатора. В результате мы получим ошибку уже в процессе выполнения скрипта. Получается, что разработчики должны держать файлы с объявлениями типов синхронизованными с исходным кодом модуля. С другой стороны, данный подход позволяет пользоваться всеми преимуществами статического анализа без необходимости переписывать весь JavaScript код приложения.
В интернете можно найти множество реализаций типизации JS модулей. Можно найти нужный пример среди тысяч .d.ts
файлов в репозитории DefinitelyTyped. Это проект, который используют разработчики, чтобы публиковать объявления типов для библиотек, загруженных в NPM. Также можно найти ответы в официальной документации, я не буду заострять внимание на этом. Расскажу про наш случай.
Наша JavaScript библиотека
Во всех наших продуктах мы используем библиотеку UI компонентов, которую написали сами внутри компании. Она отметила уже 12 версий. Есть план мигрировать на веб-компоненты, но пока мы в самом начале пути. С другой стороны, мы используем TypeScript в новых приложениях. Проблема в том, что когда команда начинает писать новый функционал (или рефакторить старый, мигрируя код на TypeScript), приходится восстанавливать часть объявлений типов для UI библиотеки. Так, в каждом репозитории у нас появились небольшие кусочки описаний типов для UI компонентов. Мы решили, что пора создать отдельный NPM пакет и описать все возможности UI библиотеки в одном месте, и вот почему:
Мы будем подключать этот пакет при инициализации нового репозитория, что позволит контролировать версию и упростит рефакторинг при обновлении.
Перестанем дублировать один и тот же код снова и снова.
Объявления типов будут выступать в качестве дополнительной документации к библиотеке. Ведь выбирать метод из подсказок IntelliSense гораздо удобнее, чем идти на сайт с описанием нужного компонента и копировать имя метода.
Сложности
Так что же тут особенного? Дело в том, что мы добавляем на страницу глобальный объект для того, чтобы использовать функционал UI библиотеки. То есть, она доступна глобально. В то же время, у нас есть наборы константных значений (список иконок, цвета тегов, типы данных в клетке таблицы и так далее), которые мы используем в наших приложениях. Обычно это набор заданных значений, с помощью которого мы можем стилизовать тот или иной компонент. Например, мы можем отрисовать один из несколько типов кнопок:
// lists/button.ts
export enum ButtonType {
Primary = "ui-primary",
Secondary = "ui-secondary",
Danger = "ui-danger"
}
Было бы неплохо сложить все такие константы и списки значений в одно место с объявлениями типов. Выходит, что в результате мы должны получить не просто файл с объявлением типов всех сущностей UI библиотеки, а целый пакет, который бы полностью отображал состояние библиотеки на конкретный момент времени. Объединяя всё сказанное выше, наша цель состояла из трёх пунктов:
Описать глобальный объект
ui
, который доступен всегда без необходимости объявлять импорт библиотеки.Все типы данных из библиотеки также должны быть доступны всегда.
Дополнительно мы хотим иметь возможность объявить импорт для констант и других доступных значений из UI библиотеки (всё это не является частью глобальной переменной
ui
). В этом случае не должно быть никаких конфликтов с пунктами 1 и 2.
Кажется, что это несложно, ведь так? Мы просто создаём .d.ts
модуль, прописываем все объявления типов для нашей библиотеки и… ок, мы не можем объявлять реальный код (константы, объекты и т.п.) внутри .d.ts
файлов. Хорошо. Тогда мы создадим реальный TypeScript модуль и положим все этим списки и интерфейсы туда. Затем, мы… как мы тогда объявим нашу глобальную переменную?..
День поисков не дал подходящего примера для такого случая. StackOverflow переполнен вопросами относительно концепта .d.ts против .ts
, и ничего похожего на задачу, которую я решаю, найти не удалось. Всё, что оставалось, это пойти читать официальную документацию и пробовать разные варианты. Вот что получилось в результате.
Начнём с нуля
Итак, у нас ничего нет. Создадим новый репозиторий и напишем интерфейсы, добавим константы, enum списки и другие значения, которые поддерживает наша библиотека компонентов. Здесь и ниже я буду приводить примеры в упрощённом виде, так как в этой статье я делаю акцент на задаче, которую решаю, а не на конкретной реализации того или иного интерфейса. Представим, что наша библиотека содержит компонент нотификации — это простое модальное окно с текстом и, может быть, кнопками. Рассмотрим один из вариантов описания методов для вызова такого компонента:
// interfaces/notification.ts
import { ButtonType } from "../lists/button";
export interface NotificationButtonConfig {
text: string;
type?: ButtonType;
}
export interface Notification {
info(text: string, buttons?: NotificationButtonConfig[]): void;
warning(text: string, buttons?: NotificationButtonConfig[]): void;
error(text: string, buttons?: NotificationButtonConfig[]): void;
}
В качестве одного из параметров мы используем enum-список ButtonType
, который уже объявляли раньше:
// lists/button.ts
export enum ButtonType {
Primary = "ui-primary",
Secondary = "ui-secondary",
Danger = "ui-danger"
}
Теперь рассмотрим самый простой случай. Наша UI библиотека предоставляет API для работы с компонентами. Значит у нас должен быть доступ к глобальному объекту библиотеки без необходимости делать импорт чего-либо:
// example/application/moduleNoImport.ts
ui.notification.info("Document has been saved!");
Как мы можем сделать объект ui
доступным глобально? Добавим его в глобальную область видимости:
// index.ts
import { UiLib } from "./interfaces/ui";
declare global {
let ui: UiLib;
}
Интерфейс UiLib
здесь содержит описания для всего API, которое поддерживает наша UI библиотека. В нашей упрощенной реализации, здесь объявлены методы для отображения нотификаций на странице:
// interfaces/ui.ts
import { Notification } from "./notification";
export interface UiLib {
notification: Notification;
}
И на этом можно закончить со случаем, который мы рассматриваем. Остаётся только настроить наш только что приготовленный NPM пакет. Мы попросим TypeScript разнести код и объявления типов в разные директории, а также явно укажем сохранять объявления типов при компиляции в JavaScript. Часть нашего tsconfig.json
будет выглядеть следующим образом:
{
"compilerOptions": {
"declaration": true,
"declarationDir": "dist/",
"outDir": "dist/es"
}
}
Последним шагом мы укажем правильные пути в нашем package.json
:
{
"main": "dist/es/index.js",
"types": "dist/index.d.ts",
}
Наконец, мы можем установить новый пакет в очередном репозитории, указать в tsconfig.json
путь для объявлений типов (так как наш NPM пакет не попадает в директорию по-умолчанию @types
) и увидеть как всё работает!
Пользуемся значениями
Усложним задачу. У нас есть enum-список ButtonType
, и мы хотим им воспользоваться. Для этого нам нужно сделать импорт значения, например вот так:
// example/application/moduleWithImport.ts
import { UiCore } from "ui-types-package";
const showNotification = (message: string): void =>
ui.notification.info(message, [
{ text: "Sad!", type: UiCore.ButtonType.Danger }
]);
Отмечу, что здесь UiCore
по факту является неймспейсом для всех констант, интерфейсов, списков — то есть для всех сущностей, которые содержит и поддерживает наша UI библиотека. Таким образом, нам не нужно думать о том, как назвать очередной интерфейс. Notification
звучит очень абстрактно, и нужен префикс, чтобы понять, кому принадлежит тот или иной объект. С другой стороны UiCore.Notification
даёт полное понимание принадлежности к библиотеке компонентов. Создавать неймспейс необязательно, я лишь нашел этот подход удобным для того, чтобы сгруппировать все вещи в одном месте.
На данный момент мы не можем сделать импорт для UiCore
из нашего пакета с типами. У нас просто нет экспорта. Исправим этот момент. Для начала объявим неймспейс:
// namespaces/core.ts
import * as notificationInterfaces from "../interfaces/notification";
import * as buttonLists from "../lists/button";
export namespace UiCore {
export import NotificationButtonConfig = notificationInterfaces.NotificationButtonConfig;
export import ButtonType = buttonLists.ButtonType;
}
Мы делаем экспорт всех доступных объектов внутри неймспейса с помощью синтаксиса export import
. Всё что нам осталось сделать, это дать доступ к нашему свежему неймспейсу извне. Для этого у нас уже есть главный модуль пакета index.ts
. Добавим в него одну строчку с экспортом UiCore
:
// index.ts
import { UiLib } from "./interfaces/ui";
export { UiCore } from "./namespaces/core";
declare global {
let ui: UiLib;
}
Два простых шага позволили нам добиться необходимого результата. Теперь мы можем сделать импорт UiCore
в модуле нашего приложения и использовать все возможности UI компонентов нашей библиотеки. Или можем придумать варианты использования, которые не покрыты текущей реализацией. Например, в нашем примере мы использовали значение ButtonType.Danger
, чтобы создать уведомление с кнопкой, окрашенной в красный цвет. Какой код нужно написать, если мы хотим использовать ButtonType
как тип параметра для очередной функции? Мы можем сделать импорт UiCore
. Но зачем?
Закрываем краевые случаи
Мы не собираемся пользоваться каким-то определённым значением и будем использовать UiCore.ButtonType
для объявления типа параметров. Значит нет смысла объявлять импорт для UiCore
. На данный момент такой способ не будет работать, так как мы не добавили UiCore
в глобальную область видимости. Например, мы не сможем написать такой метод:
// example/application/moduleNoImport.ts
const showNotificationWithButton = (
buttonText: string,
buttonType: UiCore.ButtonType // <-- TS2503: Cannot find namespace 'UiCore'
): void =>
ui.notification.info("hello world!", [
{ text: buttonText, type: buttonType }
]);
Нам нужно объявить UiCore
глобально. И здесь мы приходим к истории о том, что нельзя просто так взять существующий неймспейс и сделать его ре-экспорт в глобальной области видимости. Трюк заключается в том, что придется создать новый неймспейс с таким же именем (в нашем случае UiCore
) и объявить все объекты, которые мы собрали в одном месте, второй раз. Хорошая новость: необязательно собирать новый интерфейс из каждого отдельного модуля. Можно просто вывести наш глобальный неймспейс из существующего:
// index.ts
import { UiCore as _UiCore } from "./namespaces/core";
import { UiLib } from "./interfaces/ui";
export { _UiCore as UiCore };
declare global {
namespace UiCore {
export type NotificationButtonConfig = _UiCore.NotificationButtonConfig;
export type ButtonType = _UiCore.ButtonType;
}
let ui: UiLib;
}
Первым шагом мы переименуем импорт UiCore
для того, чтобы избежать конфликта имён. Далее мы поправим имя в экспорте для того, чтобы всё осталось на своих местах. Наконец, мы создаём новый неймспейс в глобальной области видимости и создаём псевдонимы типов для каждой сущности. Может показаться, что мы дублируем код. Отчасти это так, но на самом деле мы получаем разные сущности. Сравните:
// UiCore в глобальной области видимости
export type ButtonType = _UiCore.ButtonType;
// UiCore, содержащий ссылки на реальные значения
export import ButtonType = buttonLists.ButtonType;
Неймспейс в глобальной области видимости использует синтаксис псевдонима типа. Такой подход не работает, когда нам нужно использовать реальное значение переменной. Вместо этого, мы делаем экспорт объектов в нашем реальном неймспейсе с помощью композитного оператора export import
. Таким образом, мы собираем все интерфейсы, константы, типы данных под одним именем. Всё API нашей JavaScript библиотеки собрано в одном месте и опыт её использования не зависит от того, объявит разработчик импорт или нет
Такой трюк заставляет следить за тем, чтобы объявить очередной объект в двух местах, ведь технически, у нас два независимых неймспейса. С другой стороны, это позволяет покрыть все возможные варианты использования библиотеки типов в проекте. В результате разработчики получают готовые объявления типов для UI компонентов и могут использовать глобальный объект ui
также, как и в JavaScript модулях — без необходимости импорта чего-либо из библиотеки типов. Также отпадает необходимости создавать одни и те же константы снова и снова. Теперь все они уже объявлены и лежат в одном месте. Если вам нужна красная кнопка — вы объявляете импорт и присваиваете нужное значение. При этом остальной код продолжает работать как обычно. Мы создавали объявления типов на TypeScript 3 версии. В 4 версии появился специальный синтаксис, который работает похожим способом и не противоречит нашей реализации. Вы можете написать import type { UiCore } from "ui-types-package"
и всё будет работать, как раньше.
В заключение
Вы можете найти тысячи примеров того, как создавать объявления типов для вашей JavaScript библиотеки. Я постарался рассказать про случай, когда необходимо создать не только объявления типов, но также сохранить реальные значения полей, с которыми может работать API библиотеки. Сделать это оказалось достаточно просто:
Создаём и настраиваем новый NPM пакет.
Объявляем все сущности, поддерживаемые нашей JS библиотекой.
Добавляем описание глобального объекта, который встраивается на страницу.
Создаём неймспейс из объектов — его будем использовать при импорте.
Создаём неймспейс из типов — он находится в глобальной области видимости (выводим из существующего).
Проследим, чтобы оба неймспейса имели одинаковое имя.
Таким образом, мы смогли покрыть все варианты работы с библиотекой. При этом нам не нужно будет менять существующий TypeScript код, если понадобится сделать импорт.
Исходный код примеров можно найти здесь.
Комментарии (3)
Ulibka
10.09.2021 12:01Сергей, спасибо за статью!
Я не смог найти в документации расшифровку комбинированного оператора export import. Как правильно понять написанное?, например
export import ButtonType = buttonLists.ButtonType;
надо читать как
export (import ButtonType = buttonLists.ButtonType);
В документации правда говорится про
import ButtonType = require('buttonLists.ButtonType")
https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--requireКак правильно понимать запись
import ButtonType = buttonLists.ButtonType ?
Как правильно понимать составной оператор
export import ?
Ulibka
10.09.2021 12:10про import = прочитал тут:
Правильно я понял что import ButtonType = buttonLists.ButtonType просто создает псведоним ButtonType для типа buttonLists.ButtonType ?
iliazeus
del