.d.ts и @types: пакет без типов

границы≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Пакет установлен, программа работает, а редактор красный: «Не удалось найти файл объявления модуля». Ошибка не про ваш код — про то, что у чужой библиотеки типы не приехали. Дверей отсюда три: поставить @types, написать объявление самому или молча согласиться на any.

Разделы темы:·

Диагностика приходит на строке импорта, а импорт в ней ни при чём. Пакет установлен, файл на месте, node его прекрасно запускает — но компилятор пишет: TS7016: Не удалось найти файл объявления модуля "slugify". "…/node_modules/slugify/index.js" имеет неявный тип "any".

Вложенной причиной — редкость: компилятор прямо диктует команду. Попробуйте использовать команду "npm i --save-dev @types/slugify", если он существует, или добавьте новый файл объявления (.d.ts), содержащий "declare module 'slugify';". Читается это так же, как разбиралось в статье про чтение диагностики: верхняя строка — что случилось, вложенная — что с этим делать.

А случилось вот что. Компилятор нашёл файл, на который указывает пакет, и это оказался обычный JavaScript. Типов в нём нет, а выводить их из чужого исходника компилятор не станет: внутрь node_modules он по умолчанию не заглядывает вовсе. Остаётся признать импорт неизвестным — то есть any. Слово «неявный» в тексте не случайно: ругается та же проверка, что и на параметр без типа, noImplicitAny из strict. Выключите строгость — диагностика пропадёт, а any останется.

Что значит «пакет стал any», проще увидеть, чем описать.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики на опечатке — и TypeError на исполнении: метода formt у объекта нет. Так живёт нетипизированный пакет внутри строгого проекта: остров, на котором проверок не бывает, и всё, что с него приходит, — тоже any. Разница с any, который вы пишете сами, только одна — этот никто не выбирал.

Пакетов песочница не грузит и импортов не понимает. Поэтому чужая библиотека здесь всегда изображается руками: значение кладётся в globalThis, а рядом пишется объявление — то самое, что в настоящем проекте приехало бы из .d.ts.

Что лежит в пакетеЧто делатьКак узнать заранее
свои .d.ts рядом с кодомничего, типы уже работаютв package.json пакета есть поле types или условие types в exports; на странице пакета в npm — значок TS
типов нет, но есть @types в DefinitelyTypednpm i -D @types/имязначок DT на странице пакета; та самая подсказка во вложенной причине
ни того, ни другогонаписать объявление самомуnpm i -D @types/имя отвечает 404

Бывает наоборот: объявления в пакете есть, но компилятор до них не дотягивается. Тогда текст другой — Существуют типы в '…', но этот результат не удалось разрешить при соблюдении "экспорта" package.json. Ставить @types в этом случае бессмысленно: разговор идёт про moduleResolution, и он ждёт нас в главе про настройку проекта, в статье «Модули: import type, verbatimModuleSyntax, moduleResolution». Правило простое: увидели в диагностике слова «существуют типы» — ничего не устанавливайте, читайте про настройки.

.d.ts — это файл, в котором есть типы и нет кода. Реализацию туда не написать: тело функции, значение переменной, любой оператор компилятор в объявлении отвергнет. Разрешены только описания, и каждое верхнеуровневое помечается словом declare. Читается оно так: «такое имя в рантайме есть, я его не создаю».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор промолчал — он поверил на слово. Исполнение упало: ReferenceError: slugify is not defined. Объявление ничего не создаёт и ничего не проверяет, оно только описывает то, что, по вашему обещанию, окажется рядом.

В настоящем проекте обещание сбывается: рядом лежит node_modules/slugify/index.js, и после сборки функция действительно есть. В песочнице сбываться нечему — поэтому дальше все примеры кладут «библиотеку» в глобальную область сами.

После компиляции от .d.ts не остаётся ничего: ни файла в сборке, ни строчки в бандле. Это инструкция компилятору, а не код — ровно как аннотации типов.

Руками их почти никогда не пишут. Библиотека на TypeScript собирается с настройкой declaration: true, и рядом с каждым выданным .js компилятор кладёт .d.ts, в котором от кода остались одни сигнатуры.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В объявление из этого файла попадёт единственная строка — declare function formatPrice(sum: number, currency: string): string;. Тело выброшено, тип сохранён; в этом весь смысл файла объявлений.

Если у пакета своих объявлений нет, они могут найтись в чужих. DefinitelyTyped — общий репозиторий, куда сообщество складывает объявления к библиотекам на JavaScript; оттуда они публикуются в npm под именем @types/имя-пакета. Внутри — только .d.ts и ни строчки исполняемого кода, поэтому ставят их в devDependencies: в сборку они не едут.

Находит их компилятор сам. Разрешая импорт "lodash", он смотрит и в node_modules/lodash, и в node_modules/@types/lodash. Ни импортировать типы отдельной строкой, ни дописывать что-то в tsconfig.json не нужно: поставили пакет — импорт стал типизированным.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка идёт по объявлению, а не по коду библиотеки. TS2345: Аргумент типа "string" нельзя назначить параметру типа "number" появилась потому, что так написано в .d.ts; сам pad ничего об этом не знает и на исполнении спокойно посчитал "3" длиной. Диагностика есть, вывод тоже есть — типы не отменяют выполнение.

Обратная сторона у @types одна, зато существенная: они живут отдельной жизнью от пакета. Мажорная версия обычно совпадает, но обновиться объявления могут позже самой библиотеки — а иногда просто содержать ошибку, потому что писали их не авторы кода.

@types/node ставят почти в любой проект под Node: без него компилятор не знает ни про process, ни про Buffer.

Часть пакетов @types описывает не модуль, а глобальные имена. Такие объявления компилятор подхватывает из node_modules/@types целиком и сразу во всех файлах — потому describe и it и не требуют импорта. Иногда это мешает: глобальные имена из тестов видны и в коде приложения. Тогда список сужают опцией types в tsconfig.json, перечисляя в ней только нужные пакеты.

Пакета нет ни в DefinitelyTyped, ни со своими типами — значит, объявление пишем сами. Это обычный файл в вашем проекте, например src/types/slugify.d.ts; единственное требование — чтобы он попадал в include из tsconfig.json. Модуль описывается блоком declare module с точным именем пакета.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Внутри блока пишут ровно то, что вы импортируете: export default — для импорта по умолчанию, именованные export — для остального. Что не описано, того для компилятора не существует: импорт неописанного имени он не пропустит. Это не недостаток, а смысл упражнения — вы объявляете не библиотеку целиком, а свой договор с ней.

Есть и короткая форма — без тела вообще.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Работы на одну строку, проверок ноль. Как временная мера годится, но подпишите её комментарием: через месяц никто не вспомнит, что это заглушка, а не описание.

Второй случай — библиотека приезжает не импортом, а тегом <script> и кладёт себя в глобальную область. Модуля тогда нет вовсе, и объявляется просто имя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2345: Аргумент типа ""eror"" нельзя назначить параметру типа ""info" | "error"" — опечатку поймало ваше собственное объявление. И заметьте, строгость выбрали вы: написали бы kind: string — опечатка прошла бы молча. Объявление к чужой библиотеке описывает не её код, а то, как вы собираетесь ей пользоваться.

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

declare module "имя" работает, пока файл остаётся «скриптом» — то есть не содержит верхнеуровневых import и export. Стоит добавить любой из них, и файл становится модулем, а declare module внутри читается уже как «улучшение» существующего модуля: TS2665: Недопустимое имя модуля в приращении. Модуль "slugify" разрешается в модуль без типа в "…/node_modules/slugify/index.js", который невозможно дополнить. Сообщение сбивает с толку — кажется, что виноват пакет, хотя на самом деле изменился смысл всего файла.

Лечится тем, что верхнеуровневые импорты из файла убирают: чужой тип достаётся прямо в аннотации через import("./options").Options. А о том, как дописать поле в уже существующий чужой тип, — следующая статья главы.

У объявления нет способа себя проверить. Компилятор ни разу не сверяет .d.ts с кодом библиотеки — сверять не с чем: этот код он не читал и читать не собирается.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ноль диагностик — и TypeError на исполнении: parsed.port оказался undefined. Ошибка ровно того же сорта, что у as из статьи про враньё компилятору: вы сообщили, что знаете лучше, и вам поверили. Разница только в масштабе — объявление врёт не про одно значение, а про целую библиотеку и сразу во всех файлах.

Отсюда три привычки. Объявлять узко: меньше строк — меньше поводов соврать. То, в чём не уверены, объявлять как unknown и разбирать на границе, как в статье про данные извне: unknown в объявлении честнее выдуманной структуры. И помнить, что @types писали такие же люди: их ошибка выглядит точно так же — компилятор спокоен, программа падает.

Чем закрыть TS7016Сколько работыЧто получаете
npm i -D @types/имяодна командачужие объявления: обычно хорошие, иногда отставшие от пакета
своё объявление на нужные функциидесять минутпроверки ровно там, где вы библиотекой пользуетесь
declare module "имя"; без телаодна строкатишину компилятора и any во всём импорте

  • TS7016 означает, что пакет найден, а объявлений к нему нет: импорт получил неявный any, и виновата в этом не установка.
  • Типы приезжают только из .d.ts — из JavaScript внутри node_modules компилятор их не выводит.
  • Файл объявлений состоит из одних описаний со словом declare; кода в нём нет, и после сборки от него не остаётся ничего.
  • Готовые объявления к чужим пакетам лежат в @types/… из DefinitelyTyped: компилятор находит их сам, ставятся они в devDependencies.
  • Своё объявление пишут блоком declare module "имя" в файле-скрипте проекта, а для библиотеки из <script> — просто declare const.
  • Объявление — обещание, а не доказательство: соврать в нём так же легко, как в as, и цена та же — тишина на сборке и падение на исполнении.

1/4Пакет установлен, node его запускает, а компилятор пишет TS7016 «Не удалось найти файл объявления модуля». Что это значит?

2/4В файле объявлений написано declare function slugify(input: string): string, а самого пакета в проекте нет. Что произойдёт при запуске?

3/4Что даёт короткая форма declare module "legacy-charts"; — без тела?

4/4В @types написано, что метод возвращает { port: number }, а библиотека на самом деле возвращает строку. Когда вы об этом узнаете?

Объявление вместо заглушки

Библиотека money приехала без типов, и её наспех объявили как any. Замените заглушку точным объявлением так, чтобы компилятор поймал обе ошибки в вызовах: строку вместо суммы и валюту не из списка "AED", "USD", "RUB".

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Диагностики появились, а падение осталось на месте: объявление меняет проверку, но не поведение библиотеки. Заодно видно, что строгость — ваш выбор: currency: string вместо объединения пропустил бы "EUR" молча.

Починить лживое объявление

Объявление обещает объект с полем port, а библиотека возвращает строку "port=3000". Компилятор молчит, программа падает. Приведите объявление в соответствие с реальностью и напечатайте порт числом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сначала честное объявление: read(): string. Сразу после этого компилятор сам показывает, где было спрятано враньё, — TS2339: Свойство "port" не существует в типе "string". Теперь строку надо разобрать.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок здесь важнее результата: объявление сначала приводят к правде, а диагностики, которые после этого посыпались, и есть список мест, где программа держалась на обещании. Если бы формат строки был неизвестен, честнее было бы объявить read(): unknown и разобрать значение на границе.

Почему объявление перестало работать

В проекте лежит файл src/types/slugify.d.ts с блоком declare module "slugify" — и всё работало, пока в начало файла не добавили строку import type { Options } from "./options";. После этого компилятор выдал на блок TS2665: Недопустимое имя модуля в приращении. Модуль "slugify" разрешается в модуль без типа, который невозможно дополнить, а импорт в коде снова стал красным.

Объясните, при чём тут добавленная строка, и предложите правку, после которой объявление снова заработает.

Верхнеуровневый import превратил файл объявлений в модуль. Внутри модуля declare module "slugify" означает уже не «объявляю такой модуль», а «дополняю существующий» — и компилятор честно сообщает, что дополнять нечего: пакет-то без типов.

Правка — убрать импорт из верхнего уровня, а чужой тип достать прямо в аннотации:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Отсюда практическое правило: файл с declare module держат без верхнеуровневых import и export. Если такой файл разросся, типы выносят в соседний модуль и подтягивают их через import("…").