← Глава 27. Ошибки и исключения Содержание Глава 29. Проектировать формами: делать недопустимое непредставимым →

Глава 28. Декларации и чужой код

Глава 28. Декларации и чужой код

Типы, которые писал не ты

Ставим в проект библиотеку для работы с деньгами — пусть это будет вымышленная kopecks, считающая суммы в минимальных единицах валюты, ровно как поле total у нашего заказа. Установили, импортировали, написали вызов:

import { format } from "kopecks";

const order = { id: "A-1", total: 49900 };
const label = format(order.total); // "499,00 ₽"

Редактор подсказывает сигнатуру format(amount: number): string, всё зелёное, сборка проходит. Откуда редактор знает эту сигнатуру? Библиотека написана на чистом JavaScript — в её исходниках нет ни одной аннотации. Внутри node_modules/kopecks лежит голый .js, где format принимает что угодно и возвращает что получится. Тип (amount: number) => string пришёл не оттуда, где живёт код. Его написал кто-то ещё, в отдельном файле, и прибил к библиотеке снаружи.

Это первое, что ломает привычку. В мире, откуда приходит читатель, типы и реализация — одно целое: артефакт, который ты подключаешь, несёт свои типы внутри себя, и они авторитетны по определению, потому что компилятор той библиотеки породил их из её же исходников. В TypeScript связь разорвана. Типы JS-библиотеки — это отдельное утверждение о ней, написанное, возможно, не её авторами, возможно, для другой версии, возможно, просто неверно. Компилятор примет это утверждение на веру целиком.

Покажем, чем это кончается. Допустим, в новой мажорной версии kopecks авторы переписали format: теперь он возвращает не строку, а объект { text: string; cents: number }. А файл с типами — тот, что описывает библиотеку снаружи, — обновить забыли. Он по-прежнему обещает string:

import { format } from "kopecks";

const label = format(49900);   // тип, по версии компилятора: string
console.log(label.toUpperCase());
// компилируется без единого замечания
// рантайм: label.toUpperCase is not a function — пришёл объект

Знакомая беда из главы 1, но в новой форме. Там тип лгал, потому что мы сами соврали проверяющему через as. Здесь мы не писали ни одной аннотации — ложь приехала готовой, в чужом файле, который мы даже не открывали. Код уверенно неверен, и виноватого в своём коде не найти.

Отсюда вопрос главы: откуда вообще берутся типы сторонней библиотеки и насколько им можно верить.

.d.ts: форма без реализации

Чтобы ответить, надо посмотреть на тот самый отдельный файл. Он называется файлом деклараций и имеет расширение .d.ts — declaration file, файл объявлений. Его единственная работа — описать форму чужого JavaScript, не реализуя её.

Возьмём минимальный JS-модуль без всякого TypeScript:

// money.js — обычный JavaScript, никаких типов
export function format(amount) {
  return (amount / 100).toFixed(2) + " ₽";
}

Компилятор, увидев import { format } from "./money", ищет рядом описание формы. Мы пишем его руками в соседнем файле:

// money.d.ts — только формы, ни строчки реализации
export declare function format(amount: number): string;

Ключевое слово здесь — declare. Оно говорит проверяющему: «такая сущность существует в рантайме, вот её тип, тело искать не надо — оно не здесь». Обычное function format(...) { ... } потребовало бы тела; declare function format(...) тела не имеет и иметь не может. Это чистое утверждение о форме.

То же для отдельных значений и для целых модулей:

// объявляем, что где-то в рантайме есть константа и есть функция
declare const CURRENCY: string;
declare function round(amount: number): number;

// объявляем форму целого модуля, которого компилятор иначе не знает
declare module "legacy-cart" {
  export function addItem(sku: string, qty: number): void;
  export function total(): number;
}

Всё это — ambient-объявления (ambient declaration, окружающее объявление): они не создают сущность, а заявляют, что она уже есть где-то в окружении, и описывают её тип. Такие объявления — чистая сторона проверяющей программы из главы 3. Загляните, во что компилируется money.d.ts:

// ничего. .d.ts не порождает .js вообще

Файл деклараций стирается целиком — не частично, как аннотации внутри обычного .ts, который ещё несёт исполняемый код, а полностью: от .d.ts не остаётся ни байта. Никакого рантайм-артефакта он не создаёт и создать не может: в нём нет ни одной исполняемой инструкции, только утверждения о формах. Он существует ровно для одного адресата — type checker’а — и исчезает, не дойдя до движка. Декларация живёт целиком в той половине языка, где типы, и не имеет ни ногтя в той половине, где код.

Откуда берутся типы библиотеки

Теперь, зная, что такое .d.ts, можно перечислить все способы, какими типы попадают к сторонней библиотеке. Их ровно три, и они различаются тем, кто написал декларацию и насколько она авторитетна.

Первый: встроенные типы. Библиотека написана на TypeScript (или сгенерировала .d.ts из своих исходников) и везёт декларации в своём пакете. В package.json стоит поле types, указывающее на .d.ts:

{ "name": "kopecks", "main": "./dist/index.js", "types": "./dist/index.d.ts" }

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

Второй: @types/* из DefinitelyTyped. Библиотека на чистом JS, своих типов не везёт, но кто-то из сообщества описал её форму в отдельном пакете. Эти пакеты лежат в общем репозитории DefinitelyTyped и публикуются под скоупом @types:

npm install lodash          # сама библиотека, чистый JS
npm install -D @types/lodash # её типы, написанные посторонними людьми

Компилятор автоматически подхватывает @types/lodash из node_modules/@types. Декларация и реализация теперь — два отдельных пакета, с отдельными версиями, написанные разными людьми в разное время. Это работает удивительно хорошо ровно до тех пор, пока две версии не разойдутся.

Третий: ничего. Библиотека на JS, своих типов нет, @types никто не написал. Тогда импорт упирается в стену:

import { parse } from "obscure-lib";
// ошибка TS7016: не найден файл деклараций для модуля 'obscure-lib'.
// '.../node_modules/obscure-lib/index.js' неявно имеет тип 'any'.

Выбор за нами. Либо смириться с any — и потерять проверку на всём, что приходит из этой библиотеки. Либо написать декларацию самим, объявив ровно ту форму, которой пользуемся:

// obscure-lib.d.ts — наше собственное утверждение о чужом коде
declare module "obscure-lib" {
  export function parse(input: string): { sku: string; qty: number }[];
}

Заметьте, что во всех трёх случаях механизм один и тот же — .d.ts с ambient-объявлениями. Различается только авторство. И именно авторство решает, можно ли верить.

Декларация — непроверенное обещание о рантайме

Вот центральная честность этой главы, и она прямо продолжает каталог дыр из главы 24. Компилятор верит файлу деклараций абсолютно. Он никогда не сверяет .d.ts с реальным поведением библиотеки — да и не может: реализация для него либо голый непроверяемый JS, либо вообще отсутствует. Декларация для проверяющего — истина по определению.

Это значит, что неверная декларация даёт уверенно неверный код без единого предупреждения. Вернёмся к началу главы, но теперь назовём вещь точно. Файл kopecks.d.ts утверждает format(amount: number): string. Рантайм возвращает объект. Компилятор не имеет способа узнать о расхождении — он держит в руках только утверждение, не реализацию:

const label = format(49900);
//    ^ компилятор уверен: string. рантайм: объект.
//    декларация солгала, и сверить было не с чем.

Сравните с собственным as из главы 24. Когда вы пишете value as Order, вы хотя бы сами автор лжи — вы знаете, где её искать, и взяли ответственность на себя. Декларацию @types/kopecks писал посторонний человек, под версию, которой у вас, может быть, уже нет. Это as, поставленный за вас, без вашего ведома, на каждый вызов библиотеки. Хуже собственного as именно тем, что автор — не вы, и происхождение лжи невидимо.

Положение усугубляет один флаг, который почти всегда включён. Проверка чужих .d.ts стоит дорого, и в больших проектах её отключают:

{ "compilerOptions": { "skipLibCheck": true } }

skipLibCheck велит компилятору не проверять сами файлы деклараций — ни в @types, ни встроенные. Он перестаёт замечать даже внутренние противоречия в них: декларацию, где две части пакета описывают один тип несовместимо, он пропустит молча. Это ещё один слой доверия поверх и без того непроверяемого. Флаг ставят ради скорости и ради того, чтобы кривой @types в глубине дерева зависимостей не валил твою сборку, — разумный прагматичный размен, но размен именно в сторону «верю не глядя».

Сложите: декларация не сверяется с рантаймом, писал её, возможно, не автор библиотеки, версии дрейфуют, а skipLibCheck гасит даже проверку самих деклараций. Каждое из звеньев — осознанный компромисс ради того, чтобы натянуть типы на огромный нетипизированный мир npm. Но цепочка целиком означает простую вещь: зелёная сборка на чужих типах гарантирует ещё меньше, чем зелёная сборка на своих.

Расширение модулей и глобалов

Декларации умеют не только описывать чужое с нуля, но и дополнять уже описанное. Механизм — слияние объявлений из главы 14: два объявления одного имени компилятор сливает в одно. Снаружи библиотеки это превращается в инструмент дополнения её типов — module augmentation, дополнение модуля.

Типичный случай — middleware, дописывающая поле в чужой объект. Веб-фреймворк объявляет тип запроса Request, а наша аутентификация в рантайме навешивает на него user. Рантайм-код это сделает молча, но проверяющий о поле user не знает — оно не в его декларации. Мы дополняем её снаружи:

import { Order } from "shop-framework";

// дополняем чужой модуль: дописываем поле к его типу Request
declare module "shop-framework" {
  interface Request {
    user?: { id: string };       // навешено нашим middleware в рантайме
    currentOrder?: Order;        // навешено нашим резолвером заказа
  }
}

Теперь по всему проекту request.user и request.currentOrder известны проверяющему, хотя во встроенной декларации фреймворка их нет. Тот же приём дотягивается до глобальных типов и до самого глобального объекта:

// дополняем глобальный интерфейс Window
declare global {
  interface Window {
    analytics?: { track(event: string): void };
  }
}

window.analytics?.track("order_paid"); // теперь типизировано
export {}; // делает файл модулем, без этого declare global не сработает

declare global пробивает из модуля наружу, в глобальную область, и добавляет к ней что угодно — свойство на Window, новый глобальный тип, поле к globalThis. Это мощно: так подключают плагины, так описывают свойства, которые навешивает рантайм, так латают пробелы в чужих типах, не трогая чужой пакет. И ровно поэтому грабельно. Сюда же примыкает типизация интеропа между CommonJS и ESM, где export =, import = и флаг esModuleInterop образуют известную кашу. Оба сюжета — в разборе острых краёв ниже.

Глазами джависта

Поискать в Java аналог .d.ts — и не найти, и само отсутствие объясняет всю главу. В Java нет отдельных файлов с типами сторонней библиотеки, потому что они не нужны: jar везёт типы внутри себя, в байт-коде. Когда ты подключаешь зависимость, её типы — это не чьё-то описание поверх, а та самая информация, которую javac записал в .class из исходников библиотеки. Они авторитетны по построению и движутся с артефактом неразрывно. Никакого DefinitelyTyped, никакого @types/guava, поддерживаемого вручную посторонними, никакого дрейфа версий между типами и кодом — потому что типы и код физически в одном файле.

Причина прямо в хребте книги. Java хранит типы до рантайма (глава 3): раз они доживают до исполнения, артефакт обязан их нести, и он же становится единственным их источником. TypeScript типы стирает. JS-библиотека по построению их не несёт — в её .js типов нет и никогда не было. Значит, типы надо прибить снаружи, отдельным файлом, написанным отдельно. А всё, что прибито снаружи и поддерживается отдельно, может отстать, разойтись, соврать.

Так что .d.ts плюс DefinitelyTyped — это не кривой суррогат джавовского механизма, которого «недоделали». Это явление, у которого в Java аналога нет в принципе, и нет его не от бедности TS, а как прямое следствие выбранного размена: стираемые типы покупают совместимость со всем живым JavaScript ценой того, что к нетипизированному коду типы приходится приклеивать сбоку — и следить, чтобы они не отклеились.

Где декларации кусаются

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

Дрейф @types/*. Версии библиотеки и её типов независимы. Ставишь kopecks@3, а в дереве зависимостей застрял @types/kopecks@2 — и компилятор уверенно типизирует API, которого в установленной версии уже нет, или путает сигнатуры. Эта ложь невидима: ни одной ошибки, ни одного предупреждения, типы просто описывают не ту библиотеку, что лежит в node_modules.

declare module с any впрыскивает дыры. Когда пишешь декларацию сам и ленишься, соблазн велик:

declare module "obscure-lib";
// весь модуль молча получает тип any

Одна строчка — и любой импорт из obscure-lib это any, со всеми последствиями из главы 11: any растекается по коду, отключая проверку всюду, куда дотянется значение из этой библиотеки. Декларация, которая должна была дать типы, вместо этого тихо проделала дыру.

Дополнение действует на расстоянии. Module augmentation и declare global глобальны по своей природе. Дописал поле user? в Request в одном файле — оно появилось во всех. Зависит от того, какие файлы вообще попали в компиляцию; конфликтует, если два места дополнили один тип по-разному; и проследить, откуда у Request взялось поле user, бывает мучительно — объявление может лежать в любом из десятков файлов или вовсе в чужом пакете. Действие на расстоянии без видимого источника.

declare global загрязняет всё. Добавив свойство на Window или globalThis, ты сделал это для всего проекта и для всех, кто его типы импортирует. Глобальное пространство имён — общее и тесное; необдуманное дополнение в библиотеке отравляет типы во всех проектах, которые её ставят.

Каша CommonJS/ESM. Старые пакеты экспортируют через module.exports, что в типах выражается нестандартным export =. Новые — через ESM. Чтобы import x from "..." работал поверх module.exports, нужен флаг:

{ "compilerOptions": { "esModuleInterop": true } }

Без него import express from "express" может не сойтись с тем, что декларация описывает через export =, и приходится писать import express = require("express"). Граница двух модульных систем, размеченная типами поверх рантайма, который сам в этой границе путается, — пресловутый источник невнятных ошибок импорта.

Ambient-объявления не проверяются, и встроенные тоже врут. declare function round(amount: number): number — ничем не подкреплённое обещание; никто не сверит его с реальным round. И это касается не только сторонних @types: встроенный, везомый самой библиотекой .d.ts тоже бывает неверен — сгенерирован из устаревшего кода, поправлен руками с ошибкой. Авторитетность встроенных типов выше, но не абсолютна.

skipLibCheck пропускает их насквозь. Включённый ради скорости, он гасит проверку самих деклараций — и противоречия внутри них перестают всплывать совсем.

Динамический JS не описывается точно. Библиотека, чья функция возвращает разное в зависимости от аргументов, чьи объекты обрастают свойствами на ходу, чей экспорт — результат вызова фабрики, ложится в статическую декларацию приблизительно. .d.ts описывает то, что вообще-то не имеет одной устойчивой формы, и описывает с натяжкой.

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

Декларация — это удобство, а не гарантия

Вот переключатель этой главы. Чужой код доходит до тебя через декларации, а декларация — непроверенное утверждение о рантайме, родственник any и as из главы 24, а не доказательство. Значит, и относиться к сторонним типам надо как к внешним данным: со скепсисом, по умолчанию не доверяя сверх необходимого.

Из этого — несколько рабочих установок. Когда пишешь декларацию сам, объявляй самую узкую честную форму, которой реально пользуешься, а не размашистый any-модуль: узкая декларация и проверяет больше, и врёт по меньшей площади. Это та же дисциплина минимальной формы, что в главе 16, — описывай то, что используешь, не больше. Дополняй модули и глобалы осознанно и локально, помня, что каждое дополнение действует на весь проект; держи такие объявления собранными в одном предсказуемом месте, а не разбросанными там, где случилось.

И главное — там, где данные из библиотеки втекают в твой домен, помни, что .d.ts это не валидация. Декларация обещает, что format вернёт строку; она не проверит, что вернулось строкой на самом деле. Если от формы этого значения зависит твоя логика — заказ, сумма, статус, — ставь настоящую рантайм-проверку на границе, схемой из главы 26, ровно как на сетевые данные. Чужой код — такой же неконтролируемый источник, как чужой ввод; то, что он пришёл с типами, не делает эти типы правдой.

Это всё та же дисциплина границы, что прошла через всю часть V. Доказательство компилятора реально и сильно внутри твоего кода, где ты сам автор всех утверждений. На каждом краю — где данные приходят из сети, где их парсит JSON.parse, где их бросает throw, где их отдаёт чужая библиотека — доказательство испаряется, потому что утверждение там написал не ты и сверить его не с чем. Декларация удобна: она даёт автодополнение, ловит явные опечатки, документирует форму. Гарантией она не является. Удобство — не истина.

Итог

На этом часть V собрана: явные переопределения, рантайм-сужение, внешние данные, ошибки и — теперь — чужой код, доходящий через приклеенные снаружи декларации. Общий урок всех пяти глав один: доказательство компилятора держится внутри твоего кода и испаряется на любой границе, а твоя работа — поставить на каждой такой границе настоящую рантайм-проверку, потому что приклеенный сбоку тип обещает форму, но не сдерживает обещаний.

← Глава 27. Ошибки и исключения Содержание Глава 29. Проектировать формами: делать недопустимое непредставимым →