360 lines
57 KiB
JavaScript
360 lines
57 KiB
JavaScript
import { resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
function paragraph(text) {
|
||
return '<p>' + text + '</p>';
|
||
}
|
||
|
||
function heading(text) {
|
||
return '<h2>' + text + '</h2>';
|
||
}
|
||
|
||
function codeBlock(code) {
|
||
return '<pre><code>' + String(code).trim() + '</code></pre>';
|
||
}
|
||
|
||
function figure(src, alt, caption) {
|
||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
}
|
||
|
||
function orderedList(items) {
|
||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
}
|
||
|
||
function bulletList(items) {
|
||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function dataTable(caption, headers, rows) {
|
||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||
}
|
||
|
||
function sourceList(items) {
|
||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function createRevision(meta, bodyParts, sources) {
|
||
if (sources.length < 2) {
|
||
throw new Error(meta.slug + ': at least two primary sources are required');
|
||
}
|
||
|
||
return {
|
||
...meta,
|
||
contentHtml: bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources),
|
||
};
|
||
}
|
||
|
||
const migrationGuide = {
|
||
title: 'TypeScript Handbook: Migrating from JavaScript',
|
||
url: 'https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html',
|
||
note: 'описывает постепенный переход: JavaScript остаётся входом компилятора при allowJs, а файлы можно переводить по одному',
|
||
};
|
||
|
||
const allowJs = {
|
||
title: 'TSConfig Reference: allowJs',
|
||
url: 'https://www.typescriptlang.org/tsconfig/allowJs.html',
|
||
note: 'разрешает включать JavaScript-файлы вместе с TypeScript и тем самым не требует одномоментного переименования всего дерева',
|
||
};
|
||
|
||
const checkJs = {
|
||
title: 'TSConfig Reference: checkJs',
|
||
url: 'https://www.typescriptlang.org/tsconfig/checkJs.html',
|
||
note: 'включает диагностические сообщения для JavaScript-файлов; эквивалентный локальный маркер — комментарий @ts-check',
|
||
};
|
||
|
||
const release35 = {
|
||
title: 'TypeScript 3.5 release notes',
|
||
url: 'https://devblogs.microsoft.com/typescript/announcing-typescript-3-5/',
|
||
note: 'майский релиз 2019 года: в нём зафиксированы улучшения проверки, incremental-сборки и уточнения поведения generic-параметров',
|
||
};
|
||
|
||
const stagedConfig = [
|
||
'{',
|
||
' "compilerOptions": {',
|
||
' "target": "es5",',
|
||
' "module": "commonjs",',
|
||
' "allowJs": true,',
|
||
' "checkJs": false,',
|
||
' "noEmit": true',
|
||
' },',
|
||
' "include": ["src/**/*"]',
|
||
'}',
|
||
].join('\n');
|
||
|
||
const checkedBoundaryFixture = [
|
||
'// @ts-check',
|
||
'',
|
||
'/**',
|
||
' * @typedef {{ id: string, email: string }} Account',
|
||
' */',
|
||
'',
|
||
'/**',
|
||
' * @param {unknown} value',
|
||
' * @returns {Account | null}',
|
||
' */',
|
||
'function toAccount(value) {',
|
||
' if (!value || typeof value !== "object") return null;',
|
||
'',
|
||
' /** @type {{ [key: string]: unknown }} */',
|
||
' const record = value;',
|
||
' if (typeof record.id !== "string") return null;',
|
||
' if (typeof record.email !== "string") return null;',
|
||
'',
|
||
' return { id: record.id, email: record.email };',
|
||
'}',
|
||
'',
|
||
'module.exports = { toAccount };',
|
||
].join('\n');
|
||
|
||
const erasedTypesFixture = [
|
||
'// profile.ts',
|
||
'type Profile = { id: string, email: string };',
|
||
'',
|
||
'export function profileLabel(profile: Profile): string {',
|
||
' return profile.id + " <" + profile.email + ">";',
|
||
'}',
|
||
'',
|
||
'// profile.js after compilation',
|
||
'"use strict";',
|
||
'Object.defineProperty(exports, "__esModule", { value: true });',
|
||
'function profileLabel(profile) {',
|
||
' return profile.id + " <" + profile.email + ">";',
|
||
'}',
|
||
'exports.profileLabel = profileLabel;',
|
||
].join('\n');
|
||
|
||
const safeDecoderFixture = [
|
||
'type Member = {',
|
||
' id: string;',
|
||
' email: string;',
|
||
' status: "active" | "blocked";',
|
||
'};',
|
||
'',
|
||
'function isMember(value: unknown): value is Member {',
|
||
' if (!value || typeof value !== "object") return false;',
|
||
' const record = value as { [key: string]: unknown };',
|
||
' return typeof record.id === "string"',
|
||
' && typeof record.email === "string"',
|
||
' && (record.status === "active" || record.status === "blocked");',
|
||
'}',
|
||
'',
|
||
'export function normalizeMember(value: unknown): Member | null {',
|
||
' return isMember(value) ? value : null;',
|
||
'}',
|
||
].join('\n');
|
||
|
||
const legacyApiFixture = [
|
||
'// member-api.js остаётся JavaScript на первом шаге.',
|
||
'// @ts-check',
|
||
'',
|
||
'/** @param {string} memberId */',
|
||
'function loadMember(memberId) {',
|
||
' return request("/members/" + memberId).then(function(response) {',
|
||
' return response.body;',
|
||
' });',
|
||
'}',
|
||
'',
|
||
'module.exports = { loadMember };',
|
||
'',
|
||
'// member-screen.ts — новый узкий TypeScript-модуль.',
|
||
'import { loadMember } from "./member-api";',
|
||
'import { normalizeMember } from "./normalize-member";',
|
||
'',
|
||
'export function showMember(memberId: string): Promise<string> {',
|
||
' return loadMember(memberId).then(function(payload) {',
|
||
' const member = normalizeMember(payload);',
|
||
' if (!member) throw new Error("Ответ участника не соответствует контракту");',
|
||
' return member.email;',
|
||
' });',
|
||
'}',
|
||
].join('\n');
|
||
|
||
const practiceArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2019-10-practice-typescript-migration',
|
||
title: 'Переход на TypeScript: начать с границы, а не с массового any',
|
||
categories: ['TypeScript', 'Frontend', 'Практика'],
|
||
cover: '/assets/editorial/2019/typescript-migration-lane-2019.svg',
|
||
excerpt: 'Переименование всех файлов создаёт шум, а не типы. Разбираем постепенный маршрут TypeScript 3.5: выбрать границу данных, оставить JavaScript в сборке и считать полезную проверку.',
|
||
readingMinutes: 14,
|
||
},
|
||
[
|
||
paragraph('Симптом миграции легко узнать по pull request: несколько десятков файлов получают расширение .ts, вокруг каждого неудобного вызова появляется any, а выпуск задерживается из-за неясного набора ошибок. Проблема не в самом TypeScript. Команда начала заменять суффиксы файлов, не договорившись, какие данные становятся проверяемыми и где разрешено временно сохранить старый JavaScript. Цена — длинная ветка, в которой сборка то проходит, то нет, а новые типы почти не меняют риск ошибки.'),
|
||
paragraph('В октябре 2019 года я бы не ставил задачу «перевести приложение на TypeScript». Она не сообщает, где остановиться и как выпускать изменения. Более полезная формулировка: взять один входной контракт, сохранить рабочий путь сборки и добиться, чтобы в выбранном модуле неверные данные не уходили дальше без проверки. JavaScript синтаксис остаётся законным TypeScript-синтаксисом, а типовые записи исчезают из результата выполнения. Поэтому постепенный путь не обязан ломать действующий runtime.'),
|
||
heading('Нулевая точка: отделяем выпуск от переименования'),
|
||
paragraph('Сначала записываю, что считается рабочим выпуском до миграции. Это не абстрактное «зелёный CI», а конкретные команды и выходы: существующая сборка, один smoke-маршрут страницы и формат артефакта, который забирает сервер или CDN. Переименование .js в .ts не является доказательством, что этот контракт сохранён. Если вместе с расширением меняются module format, путь импорта и таргет, команда отлаживает три причины сразу и теряет точку возврата.'),
|
||
paragraph('Следом выбираю один узкий участок, в котором данные меняют владельца: ответ HTTP превращается в модель экрана, параметры формы — в команду API, конфигурация — в объект приложения. На такой границе тип отвечает на вопрос «что именно допускаем дальше». Внутренний вспомогательный файл без входов и выходов можно перенести позже; он не даст раннего сигнала о пользе. Граница удобна ещё и тем, что ошибку можно проверить фикстурой: передать неполный объект и убедиться, что он не попал в типизированный код.'),
|
||
figure('/assets/editorial/2019/typescript-migration-lane-2019.svg', 'Схема постепенной миграции: существующие JavaScript-модули идут в сборку, выбранная граница получает проверку через checkJs и JSDoc, затем один модуль переводится в TypeScript; рабочий выпуск остаётся отдельным контрольным пунктом', 'Миграция идёт не от каталога к каталогу, а от проверяемой границы к следующей. Сборка остаётся самостоятельным gate на каждом шаге.'),
|
||
heading('Граница важнее процента файлов'),
|
||
paragraph('Процент файлов с расширением .ts выглядит удобной метрикой, но он ничего не говорит о данных. Можно перевести сто вспомогательных функций и всё равно пропустить объект ответа без полей id и email в экран. Для первой партии я считаю только названные границы. У каждой есть источник, ожидаемая форма, место проверки и получатель. Если хотя бы одного пункта нет, запись не попадает в счётчик и не создаёт ложного ощущения прогресса.'),
|
||
dataTable(
|
||
'Матрица выбора первой границы',
|
||
['Кандидат', 'Риск без проверки', 'Минимальный контракт', 'Первый шаг', 'Что не менять сейчас'],
|
||
[
|
||
['Ответ API для карточки', 'экран ожидает поле, которого сервер не прислал', 'id, email и status перед рендером', 'описать вход через JSDoc или TypeScript-функцию нормализации', 'транспорт, URL и формат production-сборки'],
|
||
['Параметры формы', 'строка уходит в команду как неверное значение', 'состояние формы и команда отправки', 'сделать явный объект команды рядом с submit', 'все компоненты формы и стили'],
|
||
['Конфигурация окружения', 'пустой ключ даёт сбой уже после выкладки', 'обязательные строки конфигурации', 'проверить объект в одном загрузчике', 'систему secrets и способ доставки переменных'],
|
||
['Внутренний helper', 'ошибка редко пересекает модульную границу', 'локальные аргументы', 'оставить на вторую очередь', 'никакой массовой конвертации ради процента'],
|
||
],
|
||
),
|
||
paragraph('Это не рейтинг важности компонентов. Это способ не начинать с самого большого каталога. Первый контракт должен быть достаточно мал, чтобы один разработчик объяснил его в ревью, и достаточно близок к внешнему входу, чтобы TypeScript нашёл настоящий класс ошибок. Если сервис возвращает произвольный JSON, тип интерфейса сам по себе не проверит ответ в runtime: значение всё равно приходит из сети. В этом месте нужен явный код проверки, а не уверенность, что объявление type защитило процесс.'),
|
||
heading('Подключаем compiler без остановки JavaScript'),
|
||
paragraph('Опция allowJs позволяет включать .js рядом с .ts и .tsx. Для перехода это важнее, чем красивый каталог: команда может перевести один модуль, пока его соседи остаются JavaScript. checkJs добавляет диагностику в JavaScript-файлы; её можно включить на весь проект или начать с комментария @ts-check в выбранном файле. Я начинаю локально. Массовое включение проверки превращает первую итерацию в разбор старого долга, а не в проверку новой границы.'),
|
||
paragraph('Конфигурация ниже — fixture, а не универсальный tsconfig. Она показывает порядок: compiler видит и JavaScript, и TypeScript, но на первом проходе не меняет выходные файлы из-за noEmit. Параметры target и module должны повторять фактические ограничения проекта 2019 года; нельзя подставить их из чужого шаблона и объявить, что выпуск сохранён. Перед merge нужно запустить именно существующую production-команду, а не только tsc.'),
|
||
codeBlock(stagedConfig),
|
||
paragraph('После появления tsconfig полезно проверить две разные вещи. Первая — compiler вообще включает нужный файл: иначе проверка живёт в конфиге, но не действует на границу. Вторая — старый build не стал читать новый output directory или ждать файлы, которые noEmit не создаёт. Я сохраняю оба факта рядом с задачей: команду проверки типов и команду сборки. При откате можно убрать один новый module или комментарий @ts-check, не разбирать последствия переписанной половины дерева.'),
|
||
heading('Проверяем JavaScript прежде, чем переносить его'),
|
||
paragraph('Небольшой JavaScript-файл уже может дать полезную обратную связь через JSDoc. В fixture ниже функция принимает внешнее значение как unknown и выдаёт Account только после простых проверок. Здесь важен не синтаксис комментариев, а направление потока: неясное значение остаётся на краю, а модульный контракт появляется после проверки. Если заменить вход на any, обращение к record.email перестанет требовать доказательства и граница снова станет прозрачной.'),
|
||
codeBlock(checkedBoundaryFixture),
|
||
paragraph('В реальном коде проверка может быть шире: статус, вложенный объект, версия ответа или список записей. Я не пытаюсь описать весь API в первой задаче. Беру поля, от которых зависит текущий экран, и добавляю отрицательную fixture: объект без email должен вернуть null или понятную ошибку. Это даёт ревьюеру проверяемый вопрос: где именно остановятся плохие данные. Позднее контракт можно расширять, не превращая раннюю миграцию в переписывание всего backend-клиента.'),
|
||
heading('Как считать полезное покрытие'),
|
||
paragraph('Линии с аннотациями не являются метрикой качества. Для очереди миграции я веду маленькую таблицу границ и меняю её только по воспроизводимой проверке. Формула тоже должна оставаться простой: полезное покрытие = число границ, у которых назван источник, контракт, проверка и получатель, делённое на число выбранных границ. Это метод для планирования, а не результат, который можно приписать проекту без списка.'),
|
||
bulletList([
|
||
'Источник: откуда приходит значение — HTTP, форма, local storage или конфигурация.',
|
||
'Контракт: какие поля и варианты нужны следующему модулю именно сейчас.',
|
||
'Проверка: test fixture или command, на котором неполное значение отклоняется.',
|
||
'Получатель: функция или компонент, который больше не принимает неясное значение.',
|
||
'Выпускной gate: действующая команда сборки и smoke-путь, которые запускаются после изменения.',
|
||
]),
|
||
paragraph('Такой счётчик не скрывает старый JavaScript и не наказует модуль за то, что он ещё не переименован. Он показывает, где TypeScript уже ограничил неопределённость. Когда первая граница стала устойчивой, следующей беру соседнюю: например, список ответов вместо карточки. Резкое включение строгих настроек на весь репозиторий оставляю отдельной задачей с собственным объёмом и временем на исправления.'),
|
||
heading('Порядок первого выпуска'),
|
||
orderedList([
|
||
'Зафиксировать существующую build-команду и один пользовательский smoke-сценарий. Записать ожидаемый артефакт и место, где его использует приложение.',
|
||
'Выбрать одну границу данных, перечислить обязательные поля и добавить отрицательную fixture. Не включать в партию смену module format, роутинга и сборщика.',
|
||
'Добавить tsconfig с allowJs и noEmit либо согласовать аналогичный безопасный режим в текущей конфигурации. Убедиться, что compiler видит выбранный каталог.',
|
||
'Поставить @ts-check в один JavaScript-модуль или перенести одну функцию в .ts. Получить конкретную диагностику и исправить её в границе, а не заглушить any.',
|
||
'Запустить type-check, затем обычную production-сборку и smoke-сценарий. В отчёте отделить факт прохождения команд от предположений о полном покрытии.',
|
||
'Сделать маленький merge. Следующую границу брать только после того, как понятно, где хранится контракт первой и как откатывается её изменение.',
|
||
]),
|
||
heading('Границы подхода в версии 2019 года'),
|
||
paragraph('TypeScript 3.5 не превращает JavaScript в проверенный runtime. Компилятор удаляет типовые конструкции из выходного JavaScript, поэтому сеть, JSON и значения от стороннего скрипта остаются внешними входами. Их нужно проверять обычным кодом. Также checkJs может быстро обнаружить старые несогласованности; это сигнал планировать порцию работы, а не причина заменять каждое сообщение на any.'),
|
||
paragraph('Я бы не обещал в этой итерации «полную строгую типизацию». Цель скромнее и полезнее: рабочая сборка сохранена, у одной важной границы есть контракт, а некорректная fixture не проходит в типизированный модуль. Это оставляет команде следующий шаг, который можно проверить и при необходимости откатить без массовой переделки.'),
|
||
],
|
||
[migrationGuide, allowJs, checkJs, release35],
|
||
);
|
||
|
||
const mechanismArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2019-10-mechanism-typescript-migration',
|
||
title: 'Под капотом: почему типы не проверяют runtime и как any размывает границу',
|
||
categories: ['TypeScript', 'Frontend', 'Разбор механизма'],
|
||
cover: '/assets/editorial/2019/typescript-migration-type-boundary-2019.svg',
|
||
excerpt: 'Типы помогают compiler увидеть несогласованный код, но исчезают из JavaScript. Разбираем поток внешних данных, роль unknown и причину, по которой any делает миграцию декоративной.',
|
||
readingMinutes: 14,
|
||
},
|
||
[
|
||
paragraph('Сбой при миграции часто маскируется под успех: файл уже называется .ts, editor показывает подсказки, но ответ сервера с неверным полем доходит до рендера без остановки. Затем разработчик добавляет any, чтобы снять ошибку compiler, и дефект возвращается в production под видом временного исключения. Цена такого решения — не одна неточная аннотация. Непонятно, в каком модуле данные перестали быть проверяемыми, поэтому следующая ошибка снова расследуется по стеку и логам.'),
|
||
paragraph('Нужно разделить две вещи. TypeScript проверяет отношения в исходном коде и затем убирает собственные типовые записи из исполняемого JavaScript. Он не ставит проверяющий код перед JSON.parse и не меняет ответ HTTP только потому, что рядом есть interface. В 2019 году это означает простой порядок: признать внешнее значение неизвестным, проверить его обычным JavaScript-кодом и только потом передать в типизированную часть. Такой контур уже даёт практическую границу без обещания магической защиты всего приложения.'),
|
||
heading('Что именно исчезает при компиляции'),
|
||
paragraph('Type, interface, type assertion и параметрический тип нужны compiler и редактору. В runtime они не становятся объектами, которые можно спросить у браузера или Node.js. Это хорошо для совместимости: TypeScript строится поверх JavaScript, а выходной код продолжает исполняться как JavaScript. Но отсюда следует ограничение: объявление Profile не проверит сетевой payload. Если вход неверный, его нужно отклонить до вызова функции, которая полагается на Profile.'),
|
||
paragraph('Ниже одна и та же функция показана до и после компиляции. В выходном файле нет type Profile и нет аннотации параметра. Строка с return осталась, потому что она выполняется. Это удобная проверка здравого смысла для ревью: если требование должно жить в runtime, ищем условие, parser или test, а не только type alias в соседнем файле.'),
|
||
codeBlock(erasedTypesFixture),
|
||
figure('/assets/editorial/2019/typescript-migration-type-boundary-2019.svg', 'Схема контракта TypeScript: сетевой payload остаётся unknown, функция нормализации проверяет поля и выпускает Member в типизированное ядро; отдельная красная дорожка показывает, как any пропускает данные мимо проверки', 'Тип не заменяет runtime-проверку. Он становится полезным после того, как неопределённый вход остановлен или нормализован на границе.'),
|
||
heading('Поток значений: внешний вход, проверка, типизированное ядро'),
|
||
paragraph('У любого внешнего значения есть источник: HTTP-ответ, local storage, элемент формы, глобальная переменная или callback старой библиотеки. Внутри TypeScript-модуля хочется пользоваться понятной моделью. Между ними должна появиться функция, которая умеет ответить «нет». Она проверяет только свойства, от которых зависит текущая операция, и возвращает модель либо null или ошибку. Такой код выполняется в runtime, поэтому его можно покрыть fixture с неполным объектом.'),
|
||
paragraph('unknown удобен именно на этом краю. Нельзя читать свойство у unknown, пока код не доказал, что значение имеет нужную форму. Это создаёт маленькое трение в правильном месте: автор вынужден назвать условия, при которых payload считается Member. any ведёт себя иначе: он разрешает обращение к полям без проверки и переносит неопределённость дальше. В миграции any допустим как явно записанный временный долг с владельцем и сроком, но не как способ сделать список ошибок пустым.'),
|
||
codeBlock(safeDecoderFixture),
|
||
paragraph('Функция isMember не является полной схемой всего сервиса. Она не знает, какие дополнительные поля сервер может вернуть, и не пытается валидировать их заранее. Её контракт уже полезен: экрану разрешено видеть только id, email и два состояния status. Если придёт status со значением archived, функция вернёт false, а задача должна решить, как пользователь увидит это несовпадение. Скрыть его в any значит отложить это решение в наиболее дорогую точку — пользовательский сбой.'),
|
||
heading('Почему any ломает трассировку ответственности'),
|
||
paragraph('В языке any не просто «широкий тип». Он ослабляет проверку операций на значении. Если payload объявлен any, выражение payload.user.email получает разрешение без доказательства существования user и email. Следующая функция может принять результат как string, хотя источник ничего такого не гарантировал. В цепочке исчезает место, где можно остановить неверные данные, а code review начинает спорить о вкусе типов вместо конкретного контракта.'),
|
||
dataTable(
|
||
'Что compiler знает на каждом участке',
|
||
['Участок', 'Форма значения', 'Что проверяет TypeScript', 'Что должен делать runtime-код'],
|
||
[
|
||
['Ответ транспорта', 'unknown или неясный JavaScript-объект', 'не разрешает пользоваться полями без сужения, если вход честно объявлен', 'проверить наличие и тип нужных полей'],
|
||
['Нормализатор', 'unknown на входе, Member на выходе', 'связывает результат с обещанным контрактом функции', 'вернуть null или ошибку при несовпадении'],
|
||
['Компонент или formatter', 'Member', 'ловит опечатку поля и несовместимое использование статуса', 'показать модель, не повторяя сетевую проверку'],
|
||
['Переменная any', 'любая форма без доказательства', 'многие операции допускаются без полезной диагностики', 'не даёт автоматической runtime-защиты и скрывает место долга'],
|
||
],
|
||
),
|
||
paragraph('Таблица не говорит, что TypeScript всегда откажется от неверного JavaScript. Результат зависит от того, как объявлен вход и какие настройки применены к файлу. Она задаёт проверяемую модель ответственности: transport не обещает форму, normalizer проверяет форму, а остальная часть системы работает с уже названным контрактом. Это проще поддерживать, чем один общий type для всего JSON-ответа, который никто не умеет сопоставить с фактическими данными.'),
|
||
heading('allowJs и checkJs — это разные рычаги'),
|
||
paragraph('allowJs отвечает за состав входных файлов compiler: JavaScript остаётся рядом с TypeScript. Это разрешает менять модуль малыми шагами и не обрывать импорт соседей из-за одного расширения. checkJs отвечает за диагностику внутри JavaScript. В режиме проекта он включает сообщения для .js-файлов; локальный комментарий @ts-check подходит, когда сначала нужна проверка одного опасного перехода. Смешивать эти роли опасно: включённый allowJs ещё не означает, что старый JavaScript получил проверку.'),
|
||
paragraph('На практике я строю лестницу. Сначала compiler видит дерево и ничего не генерирует. Затем один boundary-файл получает @ts-check и JSDoc. После этого новый маленький модуль можно написать на .ts, оставить transport на .js и проверить их интерфейс. Лишь когда такие швы повторяются, обсуждаю расширение checkJs или общий уровень строгости. У этой последовательности есть важное свойство: каждая ошибка привязана к конкретной операции, а не к тысячи строк, до которых очередь ещё не дошла.'),
|
||
heading('TS 3.5: версия тоже участвует в контракте'),
|
||
paragraph('Статья привязана к осени 2019 года, поэтому пример не опирается на поздний синтаксис и на современные решения для схем. TypeScript 3.5 уже поддерживает unknown, type predicates, JSDoc-проверку JavaScript и настройки allowJs/checkJs. В заметках к 3.5 отдельно описаны корректировки поведения непараметризованных generic-параметров и улучшения проверки; это причина не переносить случайные рецепты из другой версии без воспроизведения на версии проекта.'),
|
||
paragraph('Из версии следует и дисциплина ревью. Если project compiler обновляется вместе с миграцией, обновление — отдельная ось риска: новые diagnostics могут быть полезны, но их нельзя выдать за результат одного переименования файла. В отчёте нужно назвать версию TypeScript, команду проверки и fixture. Тогда будущий апгрейд сможет сравнить изменения compiler с исходной точкой, а не искать, откуда внезапно пришло сообщение.'),
|
||
heading('Короткий диагностический маршрут'),
|
||
orderedList([
|
||
'Найти место, где неясное значение впервые входит в код: ответ, callback, конфигурация или форма. Не начинать с модели экрана, если источник ещё не назван.',
|
||
'Проверить, есть ли runtime-условие на нужные поля. Если есть только interface, добавить fixture с неполным объектом и увидеть, что происходит до рендера.',
|
||
'Заменить временный any на unknown в одном входе. Описать минимальное сужение через typeof, проверку объекта и допустимые варианты значений.',
|
||
'Поставить результат проверки в явный типизированный контракт функции. Следующий модуль должен принимать Member, а не объект с произвольными полями.',
|
||
'Включить @ts-check в соседнем JavaScript-файле либо добавить его в tsconfig через checkJs только после того, как причина диагностик понятна.',
|
||
'Запустить type-check и обычный build. Записать, что именно подтверждено командами, а что ещё требует интеграционного или runtime-теста.',
|
||
]),
|
||
heading('Что этот механизм не обещает'),
|
||
paragraph('Статический анализ не заменяет контракт с сервером, контрактные тесты и наблюдение за ошибками после выпуска. Runtime-проверка, в свою очередь, не делает модель автоматически удобной во всех модулях. Но вместе они делят работу честно: код на границе отвечает за фактический вход, TypeScript помогает не потерять проверенную форму дальше по графу.'),
|
||
paragraph('Если в ревью для нового файла появляется много any, я не закрываю задачу до переименования. Я спрашиваю, какие данные не смогли описать и почему. Иногда ответом будет маленький отдельный контракт, иногда — необходимость оставить модуль JavaScript до исследования API. Оба исхода лучше, чем визуально завершённая миграция, в которой compiler больше ничего не может сообщить.'),
|
||
],
|
||
[migrationGuide, allowJs, checkJs, release35],
|
||
);
|
||
|
||
const fieldArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2019-10-field-typescript-migration',
|
||
title: 'Разбор: как перевести один legacy-поток на TypeScript и не сорвать выпуск',
|
||
categories: ['TypeScript', 'Frontend', 'Полевой разбор'],
|
||
cover: '/assets/editorial/2019/typescript-migration-release-gates-2019.svg',
|
||
excerpt: 'Фикстура для старого API-модуля: JavaScript-транспорт, TypeScript-нормализатор и экран с явным контрактом. Проверяем вход, build и путь отката без массового переписывания.',
|
||
readingMinutes: 15,
|
||
},
|
||
[
|
||
paragraph('Проблема типичного legacy-потока выглядит так: JavaScript-модуль загружает карточку участника, возвращает response.body и сразу передаёт его в экран. При попытке миграции команда меняет все соседние файлы на .ts, встречает неясный payload и пишет any. Сборка может пройти, но блокировка доставки остаётся: неизвестно, где проверять новый ответ сервера и как откатить изменение, если production-команда начнёт брать другой output. Цена — ветка с большим количеством механических изменений и без одного места, которое отвечает за форму данных.'),
|
||
paragraph('Разберём маленькую fixture, а не реальный проект. Её цель — показать маршрут, который можно повторить на одном API-методе в октябре 2019 года: transport остаётся JavaScript, новый normalizer получает TypeScript, экран принимает только Member, а сборка проверяется отдельно. В fixture нет выдуманного выигрыша в скорости или процента покрытия. Есть наблюдаемые условия: неполный payload отклоняется, compiler видит связи модулей, прежняя build-команда остаётся последним gate перед merge.'),
|
||
heading('Исходный поток и место, где теряется контракт'),
|
||
paragraph('В старом коде loadMember возвращает то, что лежит в response.body. Эта функция не обязана знать всю модель экрана: она отвечает за транспорт. Ошибка начинается, когда экран обращается к body.email так, словно сеть уже доказала наличие строки. Если response.body изменился или сервер вернул ошибочный объект, сбой проявится далеко от входа. Для миграции нужен не полный rewrite клиента, а одно новое место между transport и экраном.'),
|
||
paragraph('Этим местом будет normalizeMember. На вход она принимает unknown, на выход возвращает Member или null. Такой выбор специально делает обработку отсутствующего контракта видимой в showMember: автор не может случайно отдать null в formatter. Если продукту нужно показать экран ошибки, он выбирает его там, где есть контекст страницы. Normalizer не решает UX, он лишь не выдаёт произвольный payload за известную модель.'),
|
||
figure('/assets/editorial/2019/typescript-migration-release-gates-2019.svg', 'Схема fixture: JavaScript transport возвращает неясный payload, TypeScript normalizer проверяет его и отдаёт Member экрану; рядом четыре независимых gate — fixture неверного ответа, type-check, прежняя production-сборка и smoke-сценарий', 'Разделение помогает откатить один TypeScript-модуль, не меняя транспорт и выпускной маршрут одновременно.'),
|
||
heading('Минимальный шов между JavaScript и TypeScript'),
|
||
paragraph('Первый файл можно не переименовывать. В нём появляется локальный @ts-check, чтобы compiler мог хотя бы проверить аргумент memberId и ожидаемую форму возвращаемой цепочки. Новый файл normalizer пишется на .ts. Этот шаг соответствует allowJs: JavaScript и TypeScript живут в одном проекте. Он не требует немедленной смены module resolution или перехода на другой bundler, что особенно важно, когда доставка уже завязана на старую конфигурацию.'),
|
||
codeBlock(legacyApiFixture),
|
||
paragraph('В примере строка Promise<string> экранирована в HTML, но смысл обычный: showMember обещает строку только после успешной нормализации. Если payload не соответствует контракту, функция бросает понятную ошибку. В настоящем продукте вместо throw может быть Result-подобный объект или переход в error state; статья не навязывает один способ. Проверяемое условие одно: экран не получает неясный response.body напрямую.'),
|
||
paragraph('Обратите внимание, что @ts-check не делает transport идеальным. request и response здесь намеренно не определены: это внешний legacy-контекст fixture. Для первой партии достаточно не добавлять any в новый шов и не заставлять normalizer угадывать устройство сетевой библиотеки. Когда станет ясно, какой контракт возвращает request, его можно описать отдельной задачей. Так очередь миграции растёт по границам, а не по количеству строк, которые удалось переименовать.'),
|
||
heading('Проверка runtime-входа в отдельной функции'),
|
||
paragraph('Следующий фрагмент — единственное место, где fixture признаёт конкретную форму участника. Он использует возможности TypeScript, доступные к 3.5: unknown, string literal types и type predicate. При этом условия typeof и сравнения статусов — обычный JavaScript, который останется после компиляции. Поэтому отрицательная fixture может проверить их без надежды на то, что type alias материализуется в runtime.'),
|
||
codeBlock(safeDecoderFixture),
|
||
paragraph('Для проверки файла достаточно трёх входов: корректный объект, объект без email и объект с неизвестным status. Ожидаемые результаты — Member, null, null. Это не результат запуска production-сервиса, а contract fixture, которую команда может положить рядом с модулем или выполнить в unit-тесте, когда в репозитории есть подходящий runner. В ревью нельзя заменять её фразой «сервер всегда так отвечает»: именно изменение этого предположения обычно и создаёт ошибку.'),
|
||
dataTable(
|
||
'Выпускные gates для одной migration-партии',
|
||
['Gate', 'Вход', 'Ожидаемое наблюдение', 'Что это не доказывает', 'Действие при сбое'],
|
||
[
|
||
['Contract fixture', 'валидный и два неполных payload', 'неподходящее значение не превращается в Member', 'не проверяет живой сервер и сетевой маршрут', 'уточнить normalizer или согласовать API-контракт'],
|
||
['Type-check', 'js/ts файлы выбранной границы', 'compiler видит импорт, аргумент и возвращаемое значение', 'не создаёт runtime-валидацию сам по себе', 'убрать any, сузить вход либо сузить объём партии'],
|
||
['Существующий build', 'обычная production-команда', 'получается прежний ожидаемый артефакт', 'не подтверждает все пользовательские сценарии', 'сравнить config, output path и порядок шагов'],
|
||
['Smoke-сценарий', 'тестовый ответ и экран участника', 'известный Member отображается через новый шов', 'не заменяет нагрузочный и интеграционный тест', 'вернуть маленький commit или отключить новый путь до расследования'],
|
||
],
|
||
),
|
||
paragraph('Таблица специально отделяет наблюдение от обещания. Прошедший tsc не доказывает, что API стабилен. Прошедший build не доказывает, что screen reader или все браузеры обработают страницу одинаково. Но каждый gate отвечает на свой вопрос и не позволяет спрятать отказ под общим словом «миграция завершена». Это делает отчёт коротким: команда знает, какой вход проверяли, какой сигнал получили и чего ещё не проверяли.'),
|
||
heading('Как сохранить работающую сборку'),
|
||
paragraph('Перед изменением я записываю текущую команду сборки и путь её артефактов. Если проект уже использует другой transpiler для JavaScript, TypeScript можно сначала запускать с noEmit как отдельный checker. Если tsc должен генерировать JavaScript, output directory и module target нужно сравнить с тем, что забирает existing pipeline. Нельзя одновременно сменить расширения, compiler, выходной каталог и способ загрузки модулей: при сбое не останется маленького изменения для отката.'),
|
||
paragraph('В TypeScript 3.5 есть улучшения incremental-сборки, но сама опция не является обязательной частью первой миграции. Быстрая проверка полезна только после того, как команда понимает, что именно ей измерять: одинаковый набор файлов, одна версия compiler и отдельный report для production build. В fixture я не объявляю время сборки и не делаю вывод о скорости. Сначала нужно получить повторяемые команды, потом сравнивать их на тех же условиях.'),
|
||
heading('Маршрут работы с одной границей'),
|
||
orderedList([
|
||
'Найти один внешний payload и записать, какие поля текущий экран действительно читает. Не расширять контракт полями, которые не участвуют в операции.',
|
||
'Оставить transport на JavaScript, добавить @ts-check только в этот файл при необходимости и включить allowJs, чтобы новый .ts-модуль мог импортировать его без массового rename.',
|
||
'Написать normalizer с входом unknown и отрицательными fixture для отсутствующего поля и недопустимого варианта статуса.',
|
||
'Поменять экран так, чтобы он принимал только Member после normalizer. Не передавать response.body через any ради временного прохождения compiler.',
|
||
'Запустить выбранный type-check, потом существующую production-сборку, потом smoke-путь с известным Member. Зафиксировать команды и их scope в ревью.',
|
||
'Выпустить маленький change. При сбое откатить связь normalizer со screen, сохранив transport и прежний pipeline нетронутыми; затем разбирать один фактический сигнал.',
|
||
]),
|
||
heading('Как измерять продвижение без выдуманных процентов'),
|
||
paragraph('Для этой migration-партии полезна не диаграмма «x процентов TypeScript», а журнал границ. В нём одна строка на поток: источник payload, normalizer, тип модели, fixture, команда проверки и выпускной gate. Полезное покрытие можно вычислить только как метод: число строк, где заполнены все шесть полей, делить на число выбранных потоков. Пока такой журнал не собран, числа нет и писать его в отчёт нельзя.'),
|
||
paragraph('Этот способ обнаруживает важную вещь: часть модулей вообще не готова к миграции, потому что у них нет устойчивого контракта на входе. Это не провал TypeScript. Это исследовательская задача про API или владение данными. Её лучше оставить в очереди с явным вопросом, чем заполнять unknown и any внутри экранов, пока текстово кажется, что охват растёт.'),
|
||
heading('Ограничения fixture и следующий шаг'),
|
||
paragraph('Фикстура не запускает реальный сервер, не проверяет сборочный pipeline этого репозитория и не доказывает, что response.body в конкретной библиотеке имеет указанную форму. Она нужна, чтобы показать структуру решения и честный список gates. В production-проекте normalizer требует contract-теста с API либо контролируемого тестового ответа; build требует фактического запуска в своей среде.'),
|
||
paragraph('После такого изменения следующий разумный шаг — соседний поток с тем же источником данных, а не глобальный strict mode. Если два экрана используют один endpoint, можно выделить общую функцию нормализации и добавить fixture для списка. Когда накопится несколько проверенных швов, станет видно, какие правила tsconfig действительно можно распространить. До этого момента успех миграции измеряется сохранённым выпуском и конкретными контрактами, а не длиной списка переименованных файлов.'),
|
||
],
|
||
[migrationGuide, allowJs, checkJs, release35],
|
||
);
|
||
|
||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||
|
||
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||
|
||
if (isDirectRun && process.argv.includes('--print-revisions')) {
|
||
process.stdout.write(JSON.stringify(revisions));
|
||
}
|