Съдържание на курса

Урок 7 — Модули, тестове и качество

От Dorian Chávez · основател на Hábil и архитект на интеграции ·

Време: 2 × 45 мин

Какво изграждаш: истинския проект, с тестове

Какво научаваш: ES модули (ESM), организация по отговорност, тестове с таблица от случаи, linter и форматиране

След урока ще можеш да

  • Разделиш revisor на ESM модули с назовани отговорности и изрични зависимости.
  • Импортираш стойности и типове от относителни файлове, като използваш разширения .js, съвместими с Node.
  • Напишеш тест с таблица от случаи, който проверява налични резултати и откази.
  • Тълкуваш грешка TS2305 при експорт или импорт, които не съвпадат.
  • Конфигурираш отделни команди за компилиране, тестване, проверка на стила и форматиране на проект.
  • Решаваш кой код трябва да е чист и лесен за тестване и кой принадлежи към границите на файлове, мрежа или конзола.

Защо, преди как

До предишния урок revisor вече може да върши полезна работа. Има модел Servicio, представя всяка развръзка с дискриминирано обединение Estado, проверява няколко цели едновременно и валидира конфигурацията, преди да я използва. И все пак примерите още се побират в няколко файла. Това помага да изучаваш изолирана идея, но не стига, за да издържи програма, която ще продължи да расте с HTTP API в урок 8 и с екран в урок 9.

Един голям файл има начално предимство: всичко е пред очите. Има и цена, която бързо расте. За да намериш как се представя едно състояние, обхождаш код за конфигурация, валидация, таймери и заявки. За да тестваш правило за текст, накрая импортираш или изпълняваш части, които нямат връзка с този текст. За да промениш подробност в HTTP отговора, можеш без да искаш да докоснеш правило, което таблото трябва да запази. Проблемът не е, че дългият файл е морално лош; а че престава да съобщава къде живее всяко решение.

Модулите решават тази липса на граници. Модулът е файл, който декларира какви стойности предлага с export и от какво се нуждае от други модули с import. Тази граница не е коментар, нито съвет към този, който поддържа кода: TypeScript проверява, че импортираните имена съществуват, а Node разрешава файловете, които ще се заредят по време на изпълнение. Когато reporte.ts експортира lineaReporte, обявява конкретна възможност. Когато revisor.ts импортира Servicio и Estado, оставя видимо от какви концепции се нуждае, за да координира проверка.

В JavaScript ESM модулите са също решение на исторически проблем. Преди беше често да се зареждат няколко файла чрез тагове <script> и да се зависи от глобалния ред на зареждане. Един файл можеше да предполага, че друг вече е създал глобална променлива, макар нищо в кода му да не показва тази връзка. Ако редът се променеше, грешката се появяваше при изпълнение. ESM заменя това мълчаливо споразумение с декларирана връзка: файлът, който се нуждае от нещо, го импортира с конкретен път. Node може да построи графа на зависимостите, преди да стартира програмата.

revisor се нуждае от организация, която расте, без да създава чекмеджета за всичко. Не е удобно да слагаш всички интерфейси в папка types, всички функции в utils и всичко останало в helpers. Тези имена описват техническата форма на кода, а не отговорността в домейна. С времето utils става мястото, където свършва всяка функция, която никой не е искал да назове. Да намериш нещо изисква да си спомниш къде е скрито, а не да разбереш какво прави.

По-полезна начална структура може да изглежда така:

revisor/
  package.json
  tsconfig.json
  src/
    modelo.ts
    configuracion.ts
    revisar.ts
    reporte.ts
    main.ts
    reporte.test.ts

modelo.ts описва Servicio, EstadoDisponible, EstadoFalla и Estado: не чете файлове, не отваря връзки и не отпечатва. configuracion.ts получава външни данни и ги валидира, както научи в урок 6. revisar.ts координира едновременните заявки и превръща развръзките им в състояния. reporte.ts трансформира надеждни състояния в текст или, по-нататък, в данни за API и таблото. main.ts свързва частите при стартирането на програмата. Тестът живее до кода, който пази, в src/reporte.test.ts; при компилация завършва като dist/reporte.test.js и Node го открива там.

Целта не е да има много папки. Да разделиш всяка малка функция в отделен файл също може да скрие връзката между части, които трябва да се четат заедно. Полезният въпрос е: „този файл отговаря ли на ясен въпрос на програмата?“. Ако отговорът за reporte.ts е „как представяме какво се е случило“, има отговорност. Ако една папка се казва misc, common или helpers, вероятно още няма ясен въпрос.

Тази организация има важна последица за тестовете. Функция, която получава Estado и връща низ, не се нуждае от мрежа, файлове, часовник или променливи на средата. При същите данни връща същия резултат. Такъв вид функция е евтина за тестване с таблица от случаи. Функция, която чете process.env, извиква fetch, мери време и пише в конзолата, смесва няколко граници. Може да се нуждае от интеграционни тестове, но не бива да пречи на централните правила да се тестват поотделно.

Go прави съпоставимо разделяне чрез пакети. Полезна разлика е, че Go компилира пакети и решава кои имена са публични по главната буква, докато TypeScript и JavaScript използват изрично export и import. И в двата случая основната идея е една и съща: зависимостта трябва да е видима и ограничена. Не става дума да делиш файлове за спорта; става дума да можеш да променяш една част, без да се налага да разбираш или рискуваш цялата програма.

Тестовете са втората половина на това споразумение. Компилаторът отговаря дали програмата спазва типовете: например че lineaReporte получава Estado, а не низ. Не отговаря дали правилото за представяне е това, от което си се нуждаел. Функцията може да се компилира и въпреки това да отпечата HTTP undefined, да пропусне отказ или да класифицира кода 500 като наличен. Тестът изгражда известен вход, изпълнява правило и сравнява резултата с изрично очакване.

Качеството не се свежда и до тестовете. Форматиращият инструмент прави визуалните решения последователни: отстъпите, интервалите, кавичките и новите редове престават да са спор, повтарян при всяка промяна. Linter търси шаблони, които се компилират, но обикновено крият грешки или двусмислия: променлива, декларирана и никога неизползвана, забравено обещание, условие, трудно за четене, или рисково преобразуване. Всеки инструмент отговаря на различен въпрос. tsc пита дали програмата изпълнява статичните си договори; тестовете питат дали известни случаи произвеждат очакваните резултати; linter търси признаци на проблемен код; форматиращият инструмент поддържа предвидимо представяне.

Не бива да чакаш да имаш стотици файлове, за да въведеш тези практики. Точно когато проектът е малък, е най-лесно да избереш ясни имена, да тестваш важно правило и да автоматизираш механичните проверки. После, когато revisor има сървър и екран, тези решения вече ще действат като предпазна мрежа, вместо да се превърнат в огромно и рисково почистване.

Понятията

ESM модули: файлове с изрични договори

В проект с "type": "module" в package.json Node тълкува излъчените файлове .js като ECMAScript модули, наричани още ESM. TypeScript може да анализира файлове .ts, които следват тези правила, и да излъчва съвместим JavaScript. Опцията --module nodenext казва на компилатора да спазва съвременното разрешаване на Node, включително правило, което в началото обикновено изненадва: относителните импорти трябва да записват разширението на файла, който Node ще изпълни.

Затова файл на TypeScript пише import { lineaReporte } from "./reporte.js", макар изходният файл да се казва reporte.ts. TypeScript разбира, че след компилацията Node ще зареди reporte.js. Да напишеш ./reporte оставя двусмислие, което ESM не разрешава така, както го правеше CommonJS, предишната модулна система на Node, която разрешаваше пътища и експорти с други правила.

Node 24 LTS може да изпълнява и файл .ts директно чрез премахване на типовете (type stripping): заменя синтаксиса на типовете с интервали и изпълнява получения JavaScript. Това не е компилация, нито проверка на типове; node archivo.ts не чете tsconfig.json и не прилага strict. Освен това приема само синтаксис, който може да се изтрие: enum, namespace със стойности и свойства на параметри изискват --experimental-transform-types; .tsx не се поддържа, не се допуска .ts вътре в node_modules и импортираните типове трябва да използват import type. За скрипт от един файл може да е удобно, но не е работният процес на revisor.

В частност Node, който изпълнява .ts, изисква буквални разширения .ts в import, докато този проект пише .js, за да е правилен излъченият JavaScript. Ако Node получи src/main.ts, би търсил буквално ./modelo.js в src/ и не би го намерил. Затова проектът от няколко файла се компилира с tsc и се изпълнява от dist/main.js: там наистина съществуват modelo.js, reporte.js и другите модули, които импортите декларират. Опцията erasableSyntaxOnly може да те предупреди за конструкции, които Node не би могъл да изтрие; не замества компилацията, нито тестовете.

Един модул може да експортира стойности, които съществуват при изпълнение, като функции и константи, и също типове, които служат само на компилатора. Синтаксисът import type прави видима тази разлика. Ако импортираш Estado само за да анотираш променлива, TypeScript премахва този импорт от излъчения JavaScript. Ако импортираш lineaReporte, Node трябва да я зареди, защото е функция, която се извиква по време на изпълнение.

Следващата програма има два модула. modelo.ts е собственик на договора за състоянията и на правилото да ги превръща в редове. Главният файл изгражда данни на revisor и използва експортираната функция. Никой файл не зависи от глобална променлива и не е нужно да знае как е реализиран другият, освен публичния му експорт.

{
  "name": "revisor",
  "private": true,
  "type": "module"
}
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src"]
}
// fig07_01/src/modelo.ts
export interface Servicio {
  readonly nombre: string;
  readonly url: string;
  readonly timeoutMs: number;
}

export type EstadoDisponible = {
  servicio: Servicio;
  tipo: "disponible";
  codigoHttp: number;
  duracionMs: number;
};

export type EstadoFalla = {
  servicio: Servicio;
  tipo: "falla";
  detalle: string;
};

export type Estado = EstadoDisponible | EstadoFalla;

export function lineaReporte(estado: Estado): string {
  if (estado.tipo === "disponible") {
    return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp} en ${estado.duracionMs} ms`;
  }

  return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}
// fig07_01/src/main.ts
import { lineaReporte, type Estado } from "./modelo.js";

const catalogo: Estado = {
  servicio: {
    nombre: "catálogo",
    url: "https://catalogo.example",
    timeoutMs: 1500,
  },
  tipo: "disponible",
  codigoHttp: 200,
  duracionMs: 42,
};

const pagos: Estado = {
  servicio: {
    nombre: "pagos",
    url: "https://pagos.example",
    timeoutMs: 3000,
  },
  tipo: "falla",
  detalle: "tiempo límite",
};

console.log(lineaReporte(catalogo));
console.log(lineaReporte(pagos));
$ cd fig07_01
$ npx tsc -p tsconfig.json
$ node dist/main.js
catálogo: HTTP 200 en 42 ms
pagos: falla (tiempo límite)

Границата на модула налага полезни решения. Estado се експортира, защото revisar.ts, reporte.ts, бъдещото API и таблото трябва да говорят за един и същ резултат. Частен помощник, който само подпомага lineaReporte, не е нужно да се експортира. Да го държиш без export намалява повърхността, която други файлове могат да използват по погрешка. Ако една частна функция смени името си или изчезне, никой външен модул не бива да се счупи заради това.

Вътре в revisor избягвай да създаваш циклична зависимост. Например revisar.ts може да импортира типове от modelo.ts, а текстова функция от reporte.ts може да импортира Estado от modelo.ts. Обратно, modelo.ts не бива да импортира revisar.ts, за да му поиска да проверява мрежата. Моделът описва данните; координаторът използва този модел. Ако два модула трябва да се импортират взаимно, обикновено някоя отговорност е смесена и е удобно да извадиш споделената концепция в трети, по-малък модул.

Пътят на импорта също е част от договора. Не преименувай файлове на ръка, без да обновиш импортите, и не използвай локални абсолютни пътища, които работят само на твоята машина. Проектът трябва да може да се клонира и изпълнява от всеки път. Относителните пътища с .js правят тази зависимост изрична и работят както в папката за разработка, така и в излъчения JavaScript.

Организация по отговорност: потокът на revisor

Имената на модулите трябва да следват действителния поток на програмата. revisor получава външна конфигурация, валидира услуги, координира заявки, трансформира резултати и ги представя. Тази последователност подсказва естествени отговорности:

Модул Въпрос, на който отговаря Какво не бива да прави
modelo.ts Какво е услуга и какви резултати може да произведе? Да чете JSON, да вика мрежата или да отпечатва
configuracion.ts Образуват ли външните данни валиден списък от услуги? Да решава как се представя един отчет
revisar.ts Как се проверява всяка услуга и се запазва всяка развръзка? Да знае подробности за екран
reporte.ts Как се трансформира надеждно състояние в четим изход? Да валидира JSON или да отваря връзки
main.ts Как се свързват частите при стартирането на процеса? Да съдържа дълги бизнес правила

Тази таблица не е универсален закон. Малък проект може да има modelo.ts и reporte.ts заедно, докато връзката е ясна. По-голям проект може да раздели конфигурацията от файл, променливите на средата и HTTP опциите в специфични модули. Критерият не е броят файлове; а това промяната да има очевидно място. Ако промениш текста, който ще види човек, търсиш reporte.ts. Ако се промени правилото за валиден timeoutMs, търсиш валидатора на конфигурацията.

Урок 6 вече раздели валидацията от неизвестния вход. Запази това разделение сега, когато се появяват модули. configuracion.ts може да експортира leerServicios(valor: unknown): Resultado<readonly Servicio[]>. Файлът main.ts може да прочете файл с node:fs/promises, да превърне JSON текста в unknown, да извика валидатора и едва тогава да предаде услугите на revisarTodos. Така частта, която докосва диска, е малка, а правилото за валидация остава функция, която получава стойности и връща проверим резултат.

Урок 5 раздели по сходен начин координацията от конкретната заявка. Типът Consultar получава Servicio и AbortSignal и връща обещание с отговор. В тест можеш да предадеш контролирана функция за заявка. В истинската програма main.ts ще може да построи реализация с fetch. Инжектирането на зависимости е да предадеш на функция сътрудничеството, от което се нуждае, вместо тя да го създава или да го крие вътре; тук то не позволява тест на „код 503 се превръща в отказ“ да зависи от външен сървър.

Лошата организация обикновено започва с удобни имена. utils.ts изглежда практичен, защото позволява да запазиш функция, без да решаваш къде принадлежи. После получава валидатори, преобразуватели, форматиращи функции, константи и части за мрежа. Резултатът е модул, който се импортира много, няма собствена отговорност и затруднява да се знае кои промени могат да го засегнат. Ако функция форматира състояние, принадлежи на отчета. Ако нормализира стойност на конфигурация, принадлежи на конфигурацията. Ако не се побира в никоя съществуваща отговорност, може би домейнът се нуждае от ново име.

Избягвай и да превръщаш main.ts в новия гигантски файл. Той трябва да е композиция на програмата: да получи конфигурация, да я валидира, да поиска проверка и да отпечата или да стартира сървъра. Ако main.ts съдържа петдесет реда правила за тълкуване на отговори, извади това решение в модула, който му съответства. Яснотата на main.ts служи като карта на високо ниво: който го прочете, трябва да може да разбере пътя на програмата, без да запомня всяка подробност.

Тестове с таблица от случаи: едно правило, много входове

Таблицата от случаи е колекция от входове, очаквани изходи и имена на сценарии, която споделя едно и също тяло на теста. Особено е полезна, когато функцията има много малки алтернативи. Вместо да копираш четири пъти подготовката, извикването и сравнението, пишеш веднъж механиката и добавяш редове, които описват ново поведение.

Името на всеки случай е важно. "caso 1" не помага, когато отказ се появи седмици по-късно. "disponible conserva código y duración" съобщава правилото, което се пази. "falla conserva detalle" съобщава друго. Ако се счупи вторият ред, знаеш дали трябва да прегледаш модела, функцията за представяне или очакването. Таблицата не замества мисленето; прави всяко очакване видимо и разширяемо.

Node включва node:assert/strict, стандартна библиотека за твърдения. assert.equal(real, esperado) прекратява програмата с грешка, ако стойностите са различни. В тази фигура използваме проста таблица и стабилен изход, за да можеш да я видиш като обикновена програма. В проект същият шаблон може да живее в node:test, Vitest или друг изпълнител на тестове; таблицата си остава частта, която определя очакваното поведение.

Тъй като файлът импортира модул с префикс node:, командата включва --types node. От TypeScript 6 компилаторът вече не зарежда автоматично декларациите на Node при компилиране на изолирани файлове. Този флаг само информира TypeScript за инсталираните типове; Node продължава да предоставя node:assert/strict по време на изпълнение.

{
  "name": "revisor",
  "private": true,
  "type": "module"
}
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": ["node"],
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src"]
}
// fig07_02/src/reporte.ts
export interface Servicio {
  readonly nombre: string;
  readonly url: string;
  readonly timeoutMs: number;
}

export type Estado =
  | {
      servicio: Servicio;
      tipo: "disponible";
      codigoHttp: number;
      duracionMs: number;
    }
  | {
      servicio: Servicio;
      tipo: "falla";
      detalle: string;
    };

export function lineaReporte(estado: Estado): string {
  if (estado.tipo === "disponible") {
    return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp} en ${estado.duracionMs} ms`;
  }

  return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}
// fig07_02/src/main.ts
import assert from "node:assert/strict";
import { lineaReporte, type Estado } from "./reporte.js";

const servicio = {
  nombre: "catálogo",
  url: "https://catalogo.example",
  timeoutMs: 1500,
};

const casos: readonly {
  readonly nombre: string;
  readonly entrada: Estado;
  readonly esperada: string;
}[] = [
  {
    nombre: "disponible conserva código y duración",
    entrada: {
      servicio,
      tipo: "disponible",
      codigoHttp: 204,
      duracionMs: 18,
    },
    esperada: "catálogo: HTTP 204 en 18 ms",
  },
  {
    nombre: "falla conserva detalle",
    entrada: {
      servicio,
      tipo: "falla",
      detalle: "conexión rechazada",
    },
    esperada: "catálogo: falla (conexión rechazada)",
  },
];

for (const caso of casos) {
  assert.equal(lineaReporte(caso.entrada), caso.esperada);
  console.log(`ok - ${caso.nombre}`);
}
$ cd fig07_02
$ npx tsc -p tsconfig.json
$ node dist/main.js
ok - disponible conserva código y duración
ok - falla conserva detalle

Полезният тест не покрива само щастливия път. Първият случай проверява налично състояние, но използва 204 вместо само 200, за да потвърди, че функцията запазва получения код. Вторият тества другата алтернатива на дискриминираното обединение. Ако някой промени lineaReporte и забрави да обработи отказите, вторият случай ще стане червен. Това е по-добър сигнал от процент покритие: обяснява кое поведение е престанало да се изпълнява.

Граничните стойности също трябва да имат място в таблиците ти. Ако функция класифицира успешните HTTP кодове от 200 до 299, не стига да тестваш 200 и 500. Добави 199, 200, 299 и 300. Грешките при сравнение обикновено живеят точно там: <= 300 вместо < 300 или > 200 вместо >= 200. Таблицата позволява да добавиш тези случаи като данни, без да дублираш цялата структура на теста.

Не тествай само заради покритието. Една функция може да се изпълни в тест и пак да няма съответно твърдение. Например тест, който само проверява, че lineaReporte връща низ, упражнява и двата клона, но не открива, че изходът казва "todo bien" за всяко състояние. Сравнението трябва да твърди подробността, която има значение: име, код, продължителност или съобщение за отказ.

Вътре в revisor най-бързите тестове трябва да се съсредоточат върху детерминирани функции като leerServicio, leerServicios, lineaReporte, класификатори на кодове и преобразувания на данни. Тестовете, които използват fetch, файлове или локален сървър, са полезни, но отговарят на друг въпрос: дали няколко части се интегрират правилно. Започни с чистите правила; после добави нарочни интеграционни тестове там, където граница го оправдава.

Автоматично качество: компилация, lint и форматиране

Минималната рутина за качество трябва да е лесна за запомняне и възможна за изпълнение, преди да предадеш промяна. В Node проект package.json може да събере командите, за да не е нужно никой да помни дълги опции. Пример за скриптове за revisor е следният:

{
  "type": "module",
  "scripts": {
    "compilar": "tsc",
    "verificar": "tsc --noEmit",
    "arrancar": "node dist/main.js",
    "probar": "npm run compilar && node --test \"dist/**/*.test.js\"",
    "lint": "eslint src",
    "formato": "prettier --check src"
  }
}

compilar излъчва JavaScript в dist/; verificar прави същата проверка на типовете, без да излъчва; а arrancar изпълнява излъчения вход. Това разделение е необходимо: тестовете на Node изпълняват JavaScript файловете от dist/, затова probar първо компилира и после търси dist/**/*.test.js. Измислена директория като dist/test не е тест: Node би се опитал да я зареди като модул и би се провалил, преди да открие случаи.

tsconfig.json трябва да съдържа решенията, които проектът повтаря. За Node и ESM разумна основа включва strict, module и moduleResolution със стойност nodenext, както и изричните декларации на Node. Не е нужно да копираш всяка съществуваща опция от интернет: добави опция, когато разбереш какъв договор налага.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "types": ["node"],
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}

Linter не замества компилатора. TypeScript знае, например, че функция изисква Estado; ESLint може да предупреди за променлива, която си декларирал и не си използвал, за обещание, което си оставил неизчакано, или за шаблон, който екипът е решил да избягва. Конфигурирай го с правила, които можеш да обясниш. Огромен списък от правила, копирани от друг проект, обикновено дава предупреждения, които никой не обработва. По-добре е да започнеш с малък набор, да поправиш предупрежденията и да го затегнеш само когато екипът разбере причината.

Първо инсталирай инструментите за разработка. TypeScript 7 и typescript-eslint още не се изпълняват заедно: typescript-eslint използва API на TypeScript 6. Проектът запазва TypeScript 7 за npx tsc под @typescript/native и оставя TypeScript 6 като псевдоним typescript за ESLint, чийто допълнителен изпълним файл остава достъпен като npx tsc6. Не сменяй единия с другия: те са две различни роли, докато пристигне съвместимостта.

npm install --save-dev eslint@10.11.0 @eslint/js@10.0.1 typescript-eslint@8.71.0 prettier@3.9.9 @types/node@24 typescript@npm:@typescript/typescript6@^6.0.2 @typescript/native@npm:typescript@^7.0.2

npm записва тези версии в package.json предшествани от ^ (например "^10.11.0"). За този курс няма значение: package-lock.json фиксира какво е инсталирано. Ако предпочиташ package.json да остане с точни версии, както в примера на решение 4, добави --save-exact към командата или махни ^ на ръка.

ESLint 10 използва плосък конфигурационен файл; без eslint.config.js, eslint src завършва с грешка, която съобщава, че не е намерил eslint.config.*. Тази минимална конфигурация комбинира препоръчаните правила на JavaScript и TypeScript. Prettier получава изрично решение за кавичките и ширината на реда.

import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(js.configs.recommended, ...tseslint.configs.recommended);
{
  "singleQuote": false,
  "printWidth": 100
}

Форматиращият инструмент също не замества преглед на дизайна. Prettier не знае дали revisarTodos живее в правилния модул, нито дали таблицата ти тества важна граница. Стойността му е в това да махне механичните решения от разговора. Ако целият проект използва същите отстъпи и същото подреждане на редовете, прегледът може да се съсредоточи върху промените в поведението. Изпълнявай prettier --check src в автоматичните проверки и използвай npx prettier --write src само когато искаш да приложиш форматирането към изходните файлове.

Не пренебрегвай linter, защото програмата „работи“. Предупреждение за неизчакано обещание може да значи, че процесът завършва, преди да запише резултат. Неизползвана променлива може да е остатък от валидация, която вече не се случва. Не се подчинявай и на всяко правило, без да мислиш: ако правило не представлява полезно решение за този проект, коригирай го или го премахни с видима причина. Автоматичното качество трябва да намалява грешките и триенето, а не да се превръща в шум.

В Go gofmt е естествена част от работния процес, а go vet намира конструкции, които се компилират, но изглеждат неправилни. В TypeScript екосистемата оставя повече избори: tsc, ESLint, Prettier и изпълнителят на тестове са различни инструменти. Тази гъвкавост изисква изрично решение. Щом бъдат избрани, скриптовете на проекта дават подобно усещане: кратък набор от команди, които всеки може да изпълни и които непрекъсната интеграция може да повтори.

Грешката, която ще видиш

TS2305 се появява, когато импортираш име, което модулът не експортира. С TypeScript 7.0.2 tsc отпечатва следната диагностика. Модулът съществува и пътят е правилен, но файлът експортира само revisarTodos; не експортира функция на име revisarUno.

// fig07_03/revisor.ts
export function revisarTodos(): string {
  return "revisión terminada";
}
// fig07_03.ts
import { revisarUno } from "./fig07_03/revisor.js";

console.log(revisarUno());
$ npx tsc --strict --target ES2022 --module nodenext fig07_03.ts
fig07_03.ts(2,10): error TS2305: Module '"./fig07_03/revisor.js"' has no exported member 'revisarUno'.

TS2305 не значи, че трябва да добавиш export към всичко, докато грешката изчезне. Първо реши каква част от договора ти трябва. Ако програмата трябва да координира цял списък, импортирай revisarTodos. Ако наистина ти трябва да провериш отделна услуга, създай и експортирай revisarUno като функция с ясен договор и запази revisarTodos като координатора, който я извиква за всяка услуга.

Друга честа грешка в ESM се случва, когато пропуснеш .js в относителен път. TypeScript с module: "nodenext" може да докладва TS2835 и да предложи изрично разширение. Поправката не е да напишеш .ts; напиши разширението .js на излъчения файл. Тази подробност изглежда странна само докато гледаш изходния код. Node ще разреши генерирания JavaScript и импортът трябва да описва именно този файл.

Когато Node показва ERR_MODULE_NOT_FOUND, компилацията вече е минала и проблемът е в разрешаването по време на изпълнение. Прегледай относителния път, главните и малките букви в името на файла и разширението .js. Не решавай тази грешка, като преминаваш към require или изключваш ESM: диагностиката ти показва реална разлика между името, което си импортирал, и файла, който Node може да зареди.

Провалът на тест има друго четене. Ако assert.equal съобщи, че е получил низ, различен от очаквания, не променяй веднага очакването, за да си върнеш зеленото. Първо попитай дали се е променило изискването, или кодът се е променил по погрешка. Тестът трябва да документира договорено поведение; да го променяш, за да побере какъвто и да е изход, премахва точно сигнала, който те е предупредил за промяната.

Какво се прави погрешно

  • Да създаваш модул utils, helpers или common за всичко, което няма място. Тези имена не обясняват отговорност и накрая концентрират несвързани зависимости. Назови концепцията, която притежава функцията, като configuracion, reporte или revisar; ако не можеш, може би трябва да изясниш дизайна, преди да местиш код.

  • Да импортираш относителни пътища без .js в ESM. Може да изглежда, че изходният файл трябва да се импортира с .ts или без разширение, но Node изпълнява излъчения JavaScript. Използвай пътя, който Node ще разреши, например ./modelo.js, и остави TypeScript да свърже този път с изходния файл.

  • Да експортираш всичко „за всеки случай“. Всеки експорт става потенциална зависимост на други модули. Колкото по-голяма е публичната повърхност на един файл, толкова по-трудно е да се промени вътрешността му. Експортирай типовете и функциите, от които други модули наистина се нуждаят; дръж частни помощниците на реализацията.

  • Да правиш тестове, които зависят от мрежа, часовник и файлове, за да проверят правило за текст. Тези тестове са по-бавни, по-малко детерминирани и по-трудни за диагностика. Първо отдели чистото правило, тествай го с данни, построени в паметта, и остави границите за конкретни интеграционни тестове.

  • Да тестваш само един щастлив случай. Функция, която обработва дискриминирано обединение, се нуждае от поне един случай за всяка важна алтернатива. Сравненията на диапазони се нуждаят от гранични стойности. Един изолиран случай може да мине, макар програмата да се проваля при входовете, които наистина различават правило.

  • Да гониш 100 % покритие като единствена цел. Покритие значи, че ред е бил изпълнен, а не че важно очакване е било проверено. Използвай го, за да откриваш пътища, които не си обмислил, но провери дали всеки тест може да се провали, когато се промени поведението, което е предназначен да пази.

  • Да използваш linter и форматиращия инструмент като заместител на преглед. Автоматичните инструменти откриват ограничени класове проблеми. Не могат да решат дали timeoutMs има правилна политика, дали съобщение за грешка помага да се оперира системата или дали избраният модул представя добре отговорността.

  • Да прилагаш форматиране ръчно преди всеки преглед. Ако проектът има форматиращ инструмент, остави го да свърши механичната работа. Разликите в стила, смесени с промяна на поведение, затрудняват да прегледаш какво наистина се е променило.

Упражнения

Упражнение 1 — Извади модела на revisor

Създай модул modelo.ts, който експортира Servicio, EstadoDisponible, EstadoFalla и Estado със същите договори, използвани в уроци 3 и 5. Създай главен файл, който импортира type Estado от ./modelo.js, построява едно налично състояние и едно с отказ и ги отпечатва чрез функция, експортирана от модула.

Упражнение 2 — Таблица за класифициране на кодове

Напиши clasificarCodigo(codigoHttp: number): "disponible" | "falla" в модул. Създай тест с таблица от случаи за 199, 200, 299, 300 и 503. Всеки ред трябва да има име, което описва границата или правилото, което проверява. Използвай node:assert/strict и документирай командата за компилация с --types node.

Упражнение 3 — Отдели конфигурацията за стартиране

Тръгни от leerServicios от урок 6. Постави я в configuracion.ts, запази входа като unknown и експортирай само функцията за четене и типовете, от които друг модул се нуждае. Създай малък main.ts, който получава вече парсната стойност, извиква функцията и предава списъка на проверката само ако резултатът има ok: true.

Упражнение 4 — Рутина за качество

Добави скриптовете compilar, verificar, arrancar, probar, lint и formato с договорите от този урок. Включи "types": ["node"], "rootDir": "./src", "outDir": "./dist" и "sourceMap": true в tsconfig.json. Инсталирай ESLint, typescript-eslint и Prettier с псевдонимите на TypeScript 6 и 7; изпълни всеки скрипт, поправи поне една подробност във форматирането и запиши на какъв въпрос отговаря всяка команда.

Решения

Решение 1

Модулът притежава типовете и представянето, защото и двете описват резултата на домейна. Файлът потребител импортира типа с import type, затова Node трябва да зареди само функцията, която съществува при изпълнение.

// modelo.ts
export interface Servicio {
  readonly nombre: string;
  readonly url: string;
  readonly timeoutMs: number;
}

export type Estado =
  | {
      servicio: Servicio;
      tipo: "disponible";
      codigoHttp: number;
      duracionMs: number;
    }
  | {
      servicio: Servicio;
      tipo: "falla";
      detalle: string;
    };

export function resumen(estado: Estado): string {
  if (estado.tipo === "disponible") {
    return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp}`;
  }

  return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}
// main.ts
import { resumen, type Estado } from "./modelo.js";

const estado: Estado = {
  servicio: {
    nombre: "inventario",
    url: "https://inventario.example",
    timeoutMs: 2000,
  },
  tipo: "disponible",
  codigoHttp: 200,
  duracionMs: 31,
};

console.log(resumen(estado));

Не е нужно да експортираш примерна константа, нито помощни функции, от които се нуждае само resumen. Модулът предлага минималния договор, от който друг файл се нуждае.

Решение 2

Таблицата прави видими четирите относими граници и един случай, ясно извън диапазона. Функцията пази просто правило: наличен включва от 200 до преди 300.

import assert from "node:assert/strict";

function clasificarCodigo(codigoHttp: number): "disponible" | "falla" {
  return codigoHttp >= 200 && codigoHttp < 300 ? "disponible" : "falla";
}

const casos = [
  { nombre: "199 queda debajo del rango", codigoHttp: 199, esperado: "falla" },
  { nombre: "200 inicia el rango", codigoHttp: 200, esperado: "disponible" },
  { nombre: "299 termina el rango", codigoHttp: 299, esperado: "disponible" },
  { nombre: "300 queda fuera del rango", codigoHttp: 300, esperado: "falla" },
  { nombre: "503 es falla del servidor", codigoHttp: 503, esperado: "falla" },
] as const;

for (const caso of casos) {
  assert.equal(clasificarCodigo(caso.codigoHttp), caso.esperado);
}

as const запазва литералите на всяко очакване. Не е задължително за този тест, но не позволява таблицата да се разшири до string, ако по-късно искаш да преизползваш стойностите ѝ във функция с обединение от литерали.

Решение 3

Функцията, която чете конфигурация, не бива да импортира node:fs/promises, нито да зависи от пътя на файла. Работата ѝ е да реши дали неизвестна стойност образува валиден списък. Физическото четене на файла принадлежи на външен слой, който може да живее в main.ts или в малък модул, посветен на границата с диска.

// configuracion.ts
export type Resultado<T> =
  | { ok: true; valor: T }
  | { ok: false; detalle: string };

export interface Servicio {
  readonly nombre: string;
  readonly url: string;
  readonly timeoutMs: number;
}

function esRegistro(valor: unknown): valor is Record<string, unknown> {
  return typeof valor === "object" && valor !== null && !Array.isArray(valor);
}

function leerServicio(valor: unknown): Resultado<Servicio> {
  if (!esRegistro(valor)) {
    return { ok: false, detalle: "cada servicio debe ser un objeto" };
  }

  const { nombre, url, timeoutMs } = valor;

  if (
    typeof nombre !== "string" ||
    nombre.trim() === "" ||
    typeof url !== "string" ||
    url.trim() === "" ||
    typeof timeoutMs !== "number" ||
    !Number.isSafeInteger(timeoutMs) ||
    timeoutMs <= 0
  ) {
    return { ok: false, detalle: "servicio incompleto o inválido" };
  }

  return { ok: true, valor: { nombre, url, timeoutMs } };
}

export function leerServicios(valor: unknown): Resultado<readonly Servicio[]> {
  if (!Array.isArray(valor)) {
    return { ok: false, detalle: "la configuración debe ser un arreglo" };
  }

  const servicios: Servicio[] = [];

  for (const [indice, entrada] of valor.entries()) {
    const resultado = leerServicio(entrada);

    if (!resultado.ok) {
      return { ok: false, detalle: `servicio ${indice + 1}: ${resultado.detalle}` };
    }

    servicios.push(resultado.valor);
  }

  return { ok: true, valor: servicios };
}

Решението преизползва същата защита esRegistro и същите правила от урок 6: непразен текст за име и URL и положително безопасно цяло число за timeoutMs. Валидацията запазва вход unknown и връща надежден договор, преди да започне проверката; esRegistro стеснява типа с проверки при изпълнение, а не с твърдение за тип, така че компилаторът и програмата съвпадат.

Решение 4

Скриптовете превръщат устна рутина в интерфейс на проекта. Този пълен revisor запазва src/main.ts като единствен вход, оставя теста до правилото и повтаря в истински проект решенията, обяснени по-горе. Моделът не променя формата си между тези файлове: всяко Estado пази цялата Servicio и разграничава наличност от отказ с tipo.

{
  "name": "revisor",
  "private": true,
  "type": "module",
  "scripts": {
    "compilar": "tsc",
    "verificar": "tsc --noEmit",
    "arrancar": "node dist/main.js",
    "probar": "npm run compilar && node --test \"dist/**/*.test.js\"",
    "lint": "eslint src",
    "formato": "prettier --check src"
  },
  "devDependencies": {
    "@eslint/js": "10.0.1",
    "@types/node": "24",
    "@typescript/native": "npm:typescript@^7.0.2",
    "eslint": "10.11.0",
    "prettier": "3.9.9",
    "typescript": "npm:@typescript/typescript6@^6.0.2",
    "typescript-eslint": "8.71.0"
  }
}
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "rootDir": "./src",
    "outDir": "./dist",
    "types": ["node"],
    "sourceMap": true
  },
  "include": ["src"]
}
import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(js.configs.recommended, ...tseslint.configs.recommended);
{
  "singleQuote": false,
  "printWidth": 100
}
// fig07_04/src/modelo.ts
export interface Servicio {
  readonly nombre: string;
  readonly url: string;
  readonly timeoutMs: number;
}

export type Estado =
  | {
      readonly servicio: Servicio;
      readonly tipo: "disponible";
      readonly codigoHttp: number;
      readonly duracionMs: number;
    }
  | {
      readonly servicio: Servicio;
      readonly tipo: "falla";
      readonly detalle: string;
    };
// fig07_04/src/configuracion.ts
import type { Servicio } from "./modelo.js";

export type Resultado<T> = { ok: true; valor: T } | { ok: false; detalle: string };

function esRegistro(valor: unknown): valor is Record<string, unknown> {
  return typeof valor === "object" && valor !== null && !Array.isArray(valor);
}

function leerServicio(valor: unknown): Resultado<Servicio> {
  if (!esRegistro(valor)) {
    return { ok: false, detalle: "cada servicio debe ser un objeto" };
  }

  const { nombre, url, timeoutMs } = valor;

  if (
    typeof nombre !== "string" ||
    nombre.trim() === "" ||
    typeof url !== "string" ||
    url.trim() === "" ||
    typeof timeoutMs !== "number" ||
    !Number.isSafeInteger(timeoutMs) ||
    timeoutMs <= 0
  ) {
    return { ok: false, detalle: "servicio incompleto o inválido" };
  }

  return { ok: true, valor: { nombre, url, timeoutMs } };
}

export function leerServicios(valor: unknown): Resultado<readonly Servicio[]> {
  if (!Array.isArray(valor)) {
    return { ok: false, detalle: "la configuración debe ser un arreglo" };
  }

  const servicios: Servicio[] = [];

  for (const [indice, entrada] of valor.entries()) {
    const resultado = leerServicio(entrada);

    if (!resultado.ok) {
      return { ok: false, detalle: `servicio ${indice + 1}: ${resultado.detalle}` };
    }

    servicios.push(resultado.valor);
  }

  return { ok: true, valor: servicios };
}
// fig07_04/src/reporte.ts
import type { Estado } from "./modelo.js";

export function lineaReporte(estado: Estado): string {
  if (estado.tipo === "disponible") {
    return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp} en ${estado.duracionMs} ms`;
  }

  return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}
// fig07_04/src/revisar.ts
import type { Estado, Servicio } from "./modelo.js";

export type Respuesta = { readonly codigoHttp: number; readonly duracionMs: number };
export type Consultar = (servicio: Servicio, senal: AbortSignal) => Promise<Respuesta>;

export async function revisarTodos(
  servicios: readonly Servicio[],
  consultar: Consultar,
): Promise<readonly Estado[]> {
  return Promise.all(
    servicios.map(async (servicio) => {
      try {
        const respuesta = await consultar(servicio, AbortSignal.timeout(servicio.timeoutMs));

        if (respuesta.codigoHttp >= 200 && respuesta.codigoHttp < 300) {
          return { servicio, tipo: "disponible", ...respuesta };
        }

        return { servicio, tipo: "falla", detalle: `HTTP ${respuesta.codigoHttp}` };
      } catch (error: unknown) {
        return {
          servicio,
          tipo: "falla",
          detalle: error instanceof Error ? error.message : "falla desconocida",
        };
      }
    }),
  );
}
// fig07_04/src/main.ts
import { leerServicios } from "./configuracion.js";
import { lineaReporte } from "./reporte.js";
import { revisarTodos, type Consultar } from "./revisar.js";

const configuracion = leerServicios([
  { nombre: "catálogo", url: "https://catalogo.example", timeoutMs: 1500 },
  { nombre: "pagos", url: "https://pagos.example", timeoutMs: 3000 },
]);

if (!configuracion.ok) {
  throw new Error(configuracion.detalle);
}

const consultar: Consultar = async (servicio) => {
  if (servicio.nombre === "pagos") {
    throw new Error("conexión rechazada");
  }

  return { codigoHttp: 204, duracionMs: 12 };
};

const estados = await revisarTodos(configuracion.valor, consultar);

for (const estado of estados) {
  console.log(lineaReporte(estado));
}
// fig07_04/src/reporte.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { lineaReporte } from "./reporte.js";

const servicio = { nombre: "catálogo", url: "https://catalogo.example", timeoutMs: 1500 };

test("disponible conserva código y duración", () => {
  assert.equal(
    lineaReporte({ servicio, tipo: "disponible", codigoHttp: 204, duracionMs: 18 }),
    "catálogo: HTTP 204 en 18 ms",
  );
});

test("falla conserva detalle", () => {
  assert.equal(
    lineaReporte({ servicio, tipo: "falla", detalle: "conexión rechazada" }),
    "catálogo: falla (conexión rechazada)",
  );
});
$ cd fig07_04
$ npm run lint
> lint
> eslint src
$ npm run formato
> formato
> prettier --check src

Checking formatting...
All matched files use Prettier code style!
$ npm run probar
> probar
> npm run compilar && node --test "dist/**/*.test.js"


> compilar
> tsc

✔ disponible conserva código y duración (0.341167ms)
✔ falla conserva detalle (0.057417ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.684583
$ npm run arrancar
> arrancar
> node dist/main.js

catálogo: HTTP 204 en 12 ms
pagos: falla (conexión rechazada)

npm run verificar отговаря само на типовете, без да създава файлове; npm run probar прекомпилира и изпълнява тестовете, открити в dist/; npm run lint зарежда плоската конфигурация на ESLint; а npm run formato потвърждава, че файловете в src вече спазват Prettier. npm run arrancar е малката проверка на цялата композиция. Ако трябва да приложиш форматиране, изпълни npx prettier --write src, прегледай промяната и изпълни отново npm run formato.

Как разбирам, че съм успял

  • npx tsc --version отпечатва Version 7.0.2 в проекта.
  • npm run verificar завършва без диагностика и не създава, нито обновява файлове в dist/.
  • npm run probar компилира, открива dist/reporte.test.js и докладва два одобрени теста.
  • npm run lint и npm run formato завършват успешно, след като инсталираш и конфигурираш инструментите им.
  • npm run arrancar отпечатва едно налично състояние и едно с отказ с компилирания проект.
  • Когато компилираш импорт на неекспортирано име, се появява TS2305 върху импортираното име.
  • Моят проект използва относителни импорти с разширение .js и има "type": "module" в своя package.json.
  • Моят tsconfig.json за Node включва "types": ["node"], "rootDir": "./src", "outDir": "./dist" и "sourceMap": true.

За допълнително четене

  • TypeScript Handbook: Modules — официална документация за export, import, модулите и организацията на кода; консултирано на 2 октомври 2026 г.

  • Node.js: изпълнение на TypeScript — официална документация за премахването на типовете (type stripping), синтаксиса, който може да се изтрие, и границите на директното изпълнение на файлове .ts; консултирано на 2 октомври 2026 г.

  • Node.js: ECMAScript modules — официална документация за ESM в Node и разширенията в относителните импорти; консултирано на 2 октомври 2026 г.

  • Node.js: node:assert/strict — официална документация за строгите твърдения, използвани за проверка на таблици от случаи; консултирано на 2 октомври 2026 г.

Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx