Перейти к содержанию

Описание внешней библиотеки

Не каждая зависимость проекта написана на TypeScript. Иногда библиотека уже существует как JavaScript-модуль, а приложению нужны сведения о параметрах и результате её функций. Для этого используют декларации: они описывают доступный договор, сохраняя исполняемый JavaScript отдельным файлом.

Добавим в каталог маленькую функцию оформления подписи курса. Чтобы устройство было видно полностью, зависимость включена в архив как авторская локальная библиотека legacy-course-labels, версия 1.0.0. Это учебный пример внешнего по отношению к src кода, а не рекомендация неизвестного npm-пакета. Самостоятельный lesson-23 находится в архиве продолжения. Установка, компиляция и выполнение не проводились.

Сначала прочитаем реализацию

Библиотека получает название курса и число уроков, возвращает строку подписи. Полный vendor/legacy-course-labels/index.js:

export function formatCourse(title, lessons) {
  return `${title}: ${lessons} уроков`;
}

В ней нет обращения к DOM, fetch или состоянию приложения. Она только создаёт строку. Для JavaScript преобразование значения в текст достаточно гибкое: если передать число вместо названия, шаблонная строка всё равно сможет его представить. Но публичный договор библиотеки может быть уже её технических возможностей.

В нашем случае название намеренно является строкой, а количество — числом. Это согласовано с Course, проверенным runtime-парсером. Декларация должна описывать этот договор, а не угадывать его по одному использованию функции. Если настоящая чужая библиотека допускает несколько перегрузок или иной порядок аргументов, их нужно прочитать в её источниках и документации.

Декларация экспортирует подпись

Создайте полный vendor/legacy-course-labels/index.d.ts рядом с JavaScript:

export declare function formatCourse(title: string, lessons: number): string;

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

Структура декларации экспортируемого модуля описана в шаблоне Modules .d.ts. Здесь мы используем обычный именованный экспорт, совпадающий с включённым ES-модулем. Если написать default export вместо formatCourse, описание перестанет соответствовать настоящему runtime-договору.

Не следует добавлять реализацию внутрь .d.ts или ожидать, что этот файл заменит index.js. В приложении нужен и JavaScript, и сведения для TypeScript. Первый отвечает за выполнение, второй — за статические отношения параметров и результата.

Метаданные локальной зависимости

Полный vendor/legacy-course-labels/package.json:

{
  "name": "legacy-course-labels",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "main": "./index.js",
  "types": "./index.d.ts"
}

type: module указывает формат локального JavaScript. Main описывает основной runtime-файл, types — файл деклараций для потребителя пакета. Правила подключения включённых типов рассматриваются в Declaration File Consumption.

В текущем относительном импорте декларация находится по соседству с index.js. Поля main/types описывают пакет для другого способа потребления, но здесь поиск не идёт через установленный node_modules. Локальная зависимость в package.json и непосредственный браузерный путь — два явно показанных договора, их не следует смешивать.

Версия 1.0.0 относится к этому включённому примеру. Мы сами определили его реализацию и договор; не выдаём её за опубликованную чужую версию. Private показывает, что файл не подготовлен как пакет для случайной публикации. Ни один из этих атрибутов не валидирует данные Course.

В корневом package.json появилась локальная зависимость. Полный файл:

{
  "name": "professorweb-type-lab",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "check": "tsc --noEmit",
    "build": "tsc"
  },
  "devDependencies": {
    "typescript": "6.0.2"
  },
  "dependencies": {
    "legacy-course-labels": "file:./vendor/legacy-course-labels"
  }
}

Запись file указывает на папку внутри снимка. Она делает происхождение зависимости явным и позволяет обсуждать её без загрузки стороннего кода. Однако строка не означает, что установка уже была выполнена. Node_modules и lockfile не включены; при подготовке их не создавали.

Почему браузерный импорт относительный

В обычном приложении со сборщиком можно импортировать пакет по имени и затем подготовить его код для браузера. Здесь сборщика нет, браузер загружает ES-модули непосредственно. Он не начинает искать npm-пакеты в node_modules по одному слову legacy-course-labels.

Поэтому src/view.ts обращается к включённому runtime-файлу относительным путём. TypeScript может связать такой путь с соседней декларацией index.d.ts. Полный файл представления:

import { formatCourse } from '../vendor/legacy-course-labels/index.js';
import type { Course } from './model.js';
import { countLessons } from './model.js';

export function renderCourses(list: HTMLUListElement, status: HTMLElement, courses: readonly Course[]): void {
  const nodes = courses.map(course => {
    const item = document.createElement('li');
    const link = document.createElement('a');
    link.href = course.url;
    link.textContent = formatCourse(course.title, course.lessons);
    item.append(link);
    return item;
  });
  list.replaceChildren(...nodes);
  status.textContent = `Курсов: ${courses.length}. Уроков: ${countLessons(courses)}.`;
}

После собственной компиляции читателем модуль окажется в dist/view.js. Относительный ../vendor/... оттуда снова указывает на папку vendor рядом с dist. Это осмысленная раскладка ресурсов. Если развернуть только dist и удалить vendor, браузер не получит реализацию, даже если TypeScript успешно нашёл декларацию на этапе подготовки.

Мы не применяем import type к formatCourse, потому что вызываем её. Такой импорт должен сохраниться в JavaScript. Напротив, Course импортируется как тип: его имя описывает форму параметра renderCourses, а не исполняет оформление подписи.

Ожидаемый текст карточек сохраняется: «Современный JavaScript: 20 уроков» и аналогичные подписи других записей. Сводка остаётся четыре курса / 60 уроков. Мы перенесли создание подписи в зависимость, не изменив фильтр или количество уроков. Грамматические склонения числительного эта маленькая библиотека не решает; строка намеренно проста.

Что инструмент сможет заметить

Если потребитель напишет formatCourse(course.title, '20'), второе значение не соответствует числовому параметру декларации. По смыслу типов такая запись требует исправления. Мы не приводим выдуманный текст диагностики и не утверждаем, что компилятор был запущен.

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

Но декларация может быть ложной. Например, если JavaScript начнёт возвращать объект, а index.d.ts останется со string, потребитель получит неверную уверенность. Проверка приложения доверяет объявлению внешней функции; она не доказывает автоматически истинность каждого написанного вручную .d.ts.

Для нашей зависимости сверка возможна чтением короткой реализации: шаблонная строка соответствует возвращаемому string. У большой библиотеки потребуется политика обновления деклараций вместе с runtime-версией и отдельная проверка нужных сценариев. Наличие типов уменьшает часть ошибок использования, но не отменяет ответственность за соответствие.

Где проявляется ошибка описания

Представим изменение runtime-функции: она теперь принимает объект с title и lessons вместо двух аргументов. Если оставить прежнюю декларацию, TypeScript-потребитель продолжит передавать два значения по старому договору. Проверка будет опираться на устаревшие сведения, а JavaScript получит другое. Исправить нужно вместе вызов, декларацию и реализацию либо поддержать переходный интерфейс осознанно.

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

Локальное происхождение зависимости позволяет увидеть обе стороны рядом. Для настоящего пакета сверяют его конкретную версию, включённые типы и используемый сценарий, а не только красивую автоподсказку редактора. В нашем package.json формат локального пути соответствует правилам npm. Публикации и проверки установки в этом упражнении нет.

Декларация не является проверкой внешних данных

Параметр lessons: number не превращает строковое значение из JSON в число и не проверяет положительность. Прежде чем запись попадает в view, её принимает parseCourses. Если обойти парсер утверждением as Course, библиотека не обязана обнаружить нарушение модели: её runtime вообще не содержит таких условий.

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

Также наличие .d.ts не гарантирует существование runtime-файла на сервере. Во время чтения проекта нужно проследить две связи отдельно: от TypeScript-импорта к описанию и от будущего браузерного импорта к JavaScript. В нашем снимке обе связи явны благодаря соседним index.js и index.d.ts.

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

Оглавление курса.