Расширение чужих типов: window, ImportMeta, слияние объявлений
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Чужие типы приезжают готовыми: Window описан в библиотеке браузера, ImportMeta — в библиотеке языка, типы пакета лежат у него в node_modules. Править эти файлы бессмысленно: следующая установка сотрёт правку. Но interface в TypeScript открыт — второе объявление с тем же именем не спорит с первым, а дописывает к нему поля, и этого хватает, чтобы добавить своё свойство в window.
Разговор про type и interface заканчивался одним практическим отличием: интерфейс можно объявить дважды. Компилятор не выбирает победителя и не жалуется на дубль — он складывает объявления в одно.
В одном файле так пишут редко — смысл появляется, когда объявления лежат в разных файлах: первое в чужой библиотеке, второе в вашем коде. Механизм от этого не меняется, и называется он слиянием объявлений.
Псевдоним type так не умеет. Он вводит имя окончательно, как const вводит переменную.
Две диагностики TS2300: Повторяющийся идентификатор "Track" — по одной на каждое объявление: компилятор не знает, какое из них лишнее, и подчёркивает оба. И обратите внимание на вывод: код выполнился. Типы стираются, а спор о них до браузера не доезжает.
Слияние не всесильно. Дописать новое поле можно, а переспорить старое — нет.
TS2717: Последовательные объявления свойств должны иметь один и тот же тип. Свойство "seconds" должно иметь тип "number", но имеет здесь тип "string". Правило простое: слияние добавляет, но не переписывает.
Порядок объявлений на результат не влияет — кроме перегрузок функции: у одноимённого метода сигнатуры из более позднего блока встают в списке раньше.
Обычная жизнь браузерного приложения: серверный шаблон впечатал в страницу версию сборки, счётчик аналитики повесил свою очередь команд, расширение положило флаг отладки. Всё это лежит на window — и ни о чём из этого компилятор не догадывается.
Две ошибки TS2339: Свойство "appVersion" не существует в типе "Window & typeof globalThis" — и рядом честный вывод 1.0.0. Свойство прекрасно записалось: типов в собранном коде нет, ругань компилятора выполнению не мешает.
Имя типа в тексте ошибки стоит прочитать медленно. У window два источника: интерфейс Window из библиотеки браузера и typeof globalThis — всё, что объявлено в глобальной области ваших файлов. Компилятор поискал в обоих и не нашёл. Значит, чинить надо один из них.
Под примерами этого раздела — настоящая страница: без неё компилятору неоткуда взять описание window, оно живёт в библиотеке типов браузера, а не языка.
Чинится одним объявлением. Интерфейс Window уже существует — мы просто дописываем к нему поле.
Ошибок нет, и split вызвался без единого приведения: поле не просто «разрешено», у него есть тип. Работает это и в обратную сторону — обещали строку, число теперь не пройдёт.
В браузере window.appVersion и просто appVersion — одно и то же значение: свойства window видны как глобальные имена. Для компилятора это два разных объявления, и второе мы не делали.
TS2304: Не удается найти имя "appVersion" — при том, что строкой выше то же свойство записалось без единого замечания. Глобальное имя объявляется отдельно, через declare var: это обещание «переменная с таким именем в среде есть».
У расширения глобальных типов есть цена, и платят её в браузере. Объявление ничего не создаёт: оно только сообщает компилятору, что где-то снаружи такое свойство будет. Будет ли — вопрос не к нему.
Ни одной диагностики — и TypeError при выполнении. Это ровно тот же сорт лжи, что и as: вы сказали компилятору, что поле есть, он перестал спрашивать, а реальности до вашего обещания дела нет.
Честное объявление выглядит иначе. Всё, что кладёт на window не ваш код, необязательно по своей природе: шаблон мог не отрендерить блок, расширение — не установиться, счётчик — не загрузиться.
TS18048: Возможно, "window.appConfig" имеет значение undefined — компилятор снова на вашей стороне и требует проверки. Падение при этом никуда не делось — но теперь оно предсказано, а не обнаружено пользователем. Дальше работает всё, что вы знаете про сужение: опциональная цепочка, ??, обычный if.
Если данные приходят не из вашего шаблона, а совсем со стороны, объявляйте поле как unknown и разбирайте проверками — приём из статьи про границу программы. Точный тип на window уместен ровно тогда, когда значение туда кладёте вы сами.
Примеры выше сработали потому, что файл песочницы — обычный скрипт: всё объявленное в нём на верхнем уровне сразу глобальное. Стоит появиться хотя бы одному import или export, и файл становится модулем со своей областью видимости — для значений и для типов одинаково.
Ту же логику видно в миниатюре на функции: у неё тоже своя область, и объявленный внутри интерфейс за скобки не выходит.
TS2339 ровно там, где интерфейс, казалось бы, только что объявили. В модуле происходит то же самое, только границей служит файл целиком: interface Window внутри модуля описывает какой-то местный тип с именем Window, а до глобального не дотягивается.
Дверь обратно называется declare global. В скрипте она не нужна и потому запрещена — попробуйте.
TS2669: Улучшения для глобальной области могут быть вложены во внешние модули или неоднозначные объявления модулей только напрямую. Перевод неуклюж — «улучшения» здесь augmentations, дополнения, — но смысл читается: declare global живёт только внутри модуля или внутри объявления модуля по имени.
Отсюда трюк, который встречается в каждом втором проекте: если дополнять глобальную область нужно, а импортировать в файле нечего, модулем его делают вручную — строкой export {};. Пустой экспорт ничего не экспортирует, но переключает файл в режим модуля, и declare global становится законным.
В реальных проектах такие объявления обычно лежат в отдельном .d.ts — env.d.ts, globals.d.ts. Если импортов в нём нет, это скрипт, и declare global не нужен: пишите interface Window прямо на верхнем уровне.
import.meta — объект с метаданными модуля. В библиотеке языка его тип объявлен так: interface ImportMeta {}, и в комментарии над объявлением прямо написано, что дополнять его следует слиянием. Это не забывчивость авторов, а точка расширения: что окажется в import.meta, решает среда. Браузер добавляет url, сборщики вроде Vite кладут туда env с переменными окружения.
Песочница выполняет обычный скрипт, а не модуль, — самого import.meta в ней нет. На слияние это не влияет: тип берётся из тех же объявлений, поэтому смотрим на него через переменную.
TS2339: Свойство "env" не существует в типе "ImportMeta" — та самая ошибка, которой встречает новый Vite-проект без файла vite-env.d.ts. Любопытно, где именно она встала: не на присваивании, а на чтении. Пустому типу объектный литерал ничем не противоречит, а вот прочитать из него нечего.
Лечение то же самое, что и у window, — дописать поля своим объявлением.
readonly здесь не украшение. Сборщик подставляет эти значения на этапе сборки, заменяя import.meta.env.API_URL на строковый литерал прямо в тексте программы; присваивание в такое место в лучшем случае ничего не изменит.
TS2540: Не удается задать значение для "MODE", так как это свойство, доступное только для чтения. И снова — предупреждение не отменяет выполнения: строка в выводе поменялась. Про это свойство readonly подробно говорилось в статье про неизменяемость в типах.
Важная оговорка та же, что и с window: объявив API_URL: string, вы не создали переменную окружения. Если в .env её нет, сборщик подставит undefined, а компилятор будет уверен в строке. Всё, что не обязано существовать, честнее описывать как необязательное.
| Два объявления | Сложатся | Что выйдет |
|---|---|---|
interface + interface | да | поля обоих; одноимённые обязаны совпадать по типу |
namespace + namespace | да | содержимое обоих блоков в одном имени |
namespace + функция или класс | да | свойства дописываются к самому значению |
namespace + enum | да | к перечислению добавляются функции |
type + что угодно | нет | TS2300: повторяющийся идентификатор |
class + class | нет | TS2300, а следом ещё и ошибка выполнения |
Пространства имён складываются по тому же правилу, что и интерфейсы, — и это не музейный экспонат: именно так устроено расширение чужих пакетов.
Второй блок видит version из первого так, будто их писали подряд. Про сам namespace и про то, почему в обычном коде его давно заменили модули, говорилось в статье о синтаксисе, который нельзя просто стереть.
У объявления модуля имя — строка. Синтаксис тот же, что у пространства имён, и работает он так же: если такое имя уже описано, блоки складываются.
Так описывают пакет без типов — об этом была предыдущая статья главы. Дополнение уже типизированного пакета выглядит почти так же, но с одним условием: файл обязан быть модулем, то есть импортировать сам пакет. Тогда declare module "leaflet" не заменит его типы, а допишет к ним ваши — например, поле, которое добавляет плагин.
В песочнице этого не показать: импортов здесь нет и пакетов тоже. Зато правило легко запомнить по ошибке из предыдущего раздела — declare global и дополнение модуля живут по одному закону: оба требуют файла-модуля.
И напоследок про меру. Глобальная область одна на всё приложение: свойство config на window — это имя, которое вы забрали у всех остальных, включая чужие библиотеки. Отсюда привычка называть такие поля с префиксом приложения, держать их наперечёт и объявлять необязательными. Слияние объявлений — инструмент для стыка с чужим миром, а не способ носить состояние приложения в глобальном объекте.
- Одноимённые
interfaceскладываются в один тип — на этом стоит всё расширение чужих типов;typeтак не умеет и даёт TS2300. - Своё свойство в
windowпоявляется от объявленияinterface Windowс нужным полем; без него — TS2339 «не существует в типе Window & typeof globalThis». - Объявление ничего не создаёт: пообещали поле без вопросительного знака — компилятор промолчит, а браузер бросит TypeError. Чужое пишите необязательным.
- В файле-скрипте объявления сразу глобальные; в файле-модуле (есть
importилиexport) до глобальной области ведёт толькоdeclare global. interface ImportMetaв библиотеке языка пуст намеренно — это точка расширения, куда сборщики кладутenv, а вы описываете его поля слиянием.- Складываются интерфейсы, пространства имён и пространство с функцией, классом или перечислением; на них же держится
declare moduleдля чужого пакета.
1/4В браузерном проекте написали window.appVersion = "1.0.0" и не объявили ничего. Что произойдёт?
2/4Чем два объявления interface Cfg отличаются от двух объявлений type Cfg?
3/4В файле с import наверху написали interface Window { theme: string }. Почему window.theme всё равно подчёркнут?
4/4Объявили interface Window { user: { id: number } } без вопросительного знака, а серверный шаблон в этот раз ничего не положил. Что будет с window.user.id?
Флаг отладки в window
Флаг debugMode ставит расширение браузера — когда оно установлено. Опишите свойство так, чтобы обе ошибки TS2339 ушли, а объявление осталось честным: флага может не быть вовсе.
Интерфейс Window уже существует — дописываем к нему поле. Вопросительный знак здесь обязателен: расширение может быть не установлено, и обещать булево значение безусловно было бы враньём.
Проверка if после этого не формальность: с типом boolean | undefined компилятор сам сузит значение внутри ветки. Если бы поле объявили как debugMode: boolean, ошибок тоже не было бы — но и проверка стала бы бессмысленной с точки зрения типов, а в браузере всё осталось бы по-прежнему.
Переменные окружения проекта
Сборщик кладёт в import.meta.env два значения: MODE — режим сборки, API_URL — адрес бэкенда. Опишите их слиянием так, чтобы чтение meta.env перестало быть ошибкой, а сравнение с "production" проверялось компилятором, а не оставалось игрой в угадайку со строками.
Объявляем interface ImportMeta со своим полем env. Для MODE берём не string, а объединение литералов: тогда опечатка в сравнении станет ошибкой, а не молчаливым false.
readonly отражает реальность: значения вшиты на этапе сборки, менять их в рантайме нечего. В настоящем проекте этот интерфейс кладут в отдельный .d.ts — файл без импортов остаётся скриптом, и declare global ему не нужен.
declare global, который не сработал
Код взяли из чужого проекта и вставили в свой файл. Компилятор выдал три ошибки подряд. Прочитайте первую — она объясняет остальные две — и почините пример так, чтобы window.theme прошёл проверку.
Первая ошибка — TS2669: дополнять глобальную область можно только изнутри модуля. Файл примера модулем не является, поэтому declare global здесь лишний: объявление на верхнем уровне и так глобальное. Обёртку снимаем, и заодно уходят два TS2339 — они были следствием того, что слияние не состоялось.
В исходном проекте код был правильным: там файл содержал импорты, то есть был модулем, и без declare global объявление осталось бы местным. Одна и та же строка нужна или запрещена в зависимости от того, есть ли в файле import или export.