08 · JavaScript/TypeScript для AQA
tsconfig і TypeScript у тестовому проєкті
Зміст
Ти написав тест, у ньому явна помилка типів — редактор підсвітив червоним, — а npx playwright test спокійно запускає його й проходить зелено. Або навпаки: код виглядає ідеально, але CI падає на кроці tsc з десятком error TS2345, яких у тебе на екрані не було. Обидві ситуації — не магія й не баг інструмента. Це наслідок того, як TypeScript влаштований у тестовому проєкті: типи й виконання живуть окремо, а керує ними файл, у який мало хто заглядає, — tsconfig.json.
Для AQA це не академічна тема, а питання довіри до власної сюїти. Якщо ранер ігнорує типи, то TypeScript у тестах — лише підказки редактора, а не гарантія; справжня перевірка вмикається окремою командою, і якщо її немає в пайплайні, половина користі від TS просто не працює. Ця глава — про те, хто читає tsconfig, чому їх насправді кілька споживачів, і як зробити так, щоб «зелений тест» і «код без помилок типів» означали одне й те саме.
Що таке tsconfig.json і хто його читає
tsconfig.json — це конфігурація компілятора TypeScript. Його наявність у корені папки означає одне: «це коренева тека TypeScript-проєкту, і ось за якими правилами тут трактується код». Правила стосуються двох речей — як перевіряти типи і як перетворювати (транспілювати) TS-синтаксис у звичайний JavaScript, який реально виконає рушій.
Ключове, що плутає новачків: tsconfig.json читає не один інструмент, а щонайменше троє, і роблять вони з нього різні висновки.
Один і той самий файл, той самий tsconfig — три різні поведінки. Редактор (через мовний сервіс tsserver) показує тобі червоні підкреслення, але нічого не запускає. Ранер бере файл, викидає з нього анотації типів і виконує результат — типи його не цікавлять взагалі. І тільки третій споживач, tsc --noEmit, робить те, заради чого TypeScript існує: проганяє повну перевірку типів і повертає ненульовий код виходу, якщо щось не сходиться. Уся плутанина «редактор червоний, а тест зелений» — це просто різниця між першим і другим споживачем. Решта глави — про те, як їх примирити.
Ключові опції: strict, target, module, paths
tsconfig.json має десятки опцій, більшість — у секції compilerOptions. Для тестового проєкту критичних кілька.
strict — головний перемикач суворості. Це не одна опція, а сімейство: strict: true вмикає одразу strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization та інші. Саме strictNullChecks змушує TS розрізняти User і User | null — і ловить звернення до можливо відсутнього значення ще до запуску. Без strict TypeScript деградує до «JavaScript із коментарями»: any розповзається всюди, і перевірка майже нічого не гарантує. Детальніше про сам суворий режим — у главі «TypeScript: типи, інтерфейси та строгий режим». Для нового тестового проєкту strict: true — розумний дефолт.
target — версія ECMAScript, до якої TS зводить синтаксис (наприклад, ES2021, ES2022, ESNext). Впливає на те, які сучасні конструкції транспілюються в старіші, а які лишаються як є, і який набір вбудованих типів (lib) доступний. У тестовому проєкті код виконує Node.js, а не старий браузер, тож target ставлять під версію свого Node — немає сенсу зводити код до стародавнього ES, який Node і так розуміє нативно. Покладатися на дефолт не варто: він історично дуже консервативний.
module — яку модульну систему отримає згенерований код: commonjs, esnext, nodenext. Це визначає, у що перетворяться твої import/export — у require(...) чи в нативні ES-модулі. Тема CommonJS проти ES Modules — окрема й підступна; вона розібрана в главі «Модулі та npm: import/export, package.json». Поруч часто йде moduleResolution (як TS шукає модулі за їхніми іменами) зі значеннями node16, nodenext чи bundler.
paths — псевдоніми шляхів. Замість ../../../pages/login пишеш @pages/login, а в конфізі мапиш @pages/* на src/pages/* (зазвичай разом із baseUrl). Зручно — але тут схована класична пастка, якій присвячено окремий блок нижче.
Ще кілька опцій, без яких тестовий проєкт зазвичай не живе:
| Опція | Навіщо в тестах |
|---|---|
esModuleInterop | Дозволяє import x from 'cjs-lib' для CommonJS-бібліотек без синтаксичних танців |
skipLibCheck | Пропускає перевірку типів усередині .d.ts залежностей — помітно швидший tsc, менше чужого шуму |
types | Обмежує, які глобальні @types-пакети підхоплюються (напр., лишити тільки node) |
isolatedModules | Забороняє конструкції, які не можна транспілювати пофайлово — узгоджує проєкт із швидкими транспіляторами (esbuild, swc) |
noEmit | Не генерувати вихідні .js — режим «тільки перевірка» |
Пастка paths: аліас, який працює в редакторі й ламається в рантаймі
paths — це виключно compile-time-функція TypeScript. Компілятор і редактор розуміють @pages/login і резолвлять його для перевірки типів. Але при транспіляції TS не переписує ці аліаси у згенерованому коді — у виводі лишається буквально @pages/login. А Node.js такого шляху не знає: для нього це не relative-шлях і не пакет у node_modules, тож у рантаймі — Cannot find module '@pages/login'.
Виглядає як «TypeScript зламався», а насправді — аліаси paths самі собою не працюють під час виконання; їх має розв'язати ще хтось: бандлер, окремий рантайм-резолвер (tsconfig-paths) або відповідний loader у tsx/ts-node. Playwright, наприклад, поважає paths зі свого tsconfig при запуску тестів; довільний скрипт через голий node — ні. Тому перш ніж заводити красиві аліаси, переконайся, що твій спосіб запуску вміє їх розв'язувати.
Чому ранер виконує TS без перевірки типів
Ось центральний факт цієї глави. Більшість сучасних інструментів, що запускають TS-тести, роблять транспіляцію без перевірки типів (transpile-only). Технічно прибрати з файлу анотації типів — це майже суто синтаксична операція: пройтися по файлу й викинути : string, interface, as User. Її можна зробити швидко й пофайлово, не будуючи всю карту типів проєкту. А от повна перевірка типів — операція дорога: щоб зрозуміти, чи сумісні два типи, компілятор має розгорнути весь граф типів, дженерики, імпорти. Тому інструменти, заточені на швидкість запуску, свідомо цей крок пропускають.
Хто саме працює в transpile-only:
- Playwright Test має вбудовану підтримку TS: він транспілює тести на льоту (під капотом Babel із пресетом
@babel/preset-typescript) і типи не перевіряє. Тест із помилкою типів запуститься й може пройти. tsxпобудований на esbuild і принципово не робить перевірки типів ніколи.ts-nodeу типовому режимі типи перевіряє (використовує сам компілятор TS), але його часто вмикають уtranspileOnlyабо через swc заради швидкості — і тоді перевірка теж зникає.- CodeceptJS зазвичай реєструє
ts-nodeдля запуску.ts— тобто успадковує його налаштування.
Практичний висновок жорсткий: не покладайся на ранер як на гарантію типів. Зелений прогін не означає, що код типобезпечний, — він означає, що код синтаксично коректний і його логіка спрацювала. Помилка типів могла просто поїхати в рантайм разом зі зрізаними анотаціями.
Саме тут потрібен tsc --noEmit. Це запуск повного компілятора TypeScript у режимі «нічого не генеруй, лише перевір»:
npx tsc --noEmit
tsc прочитає tsconfig.json, збере весь проєкт, прожене повну перевірку типів і поверне ненульовий код виходу при першій же помилці — а завдяки --noEmit не насмітить купою .js-файлів поряд із тестами. Це та сама перевірка, що дає редактор, але для всього проєкту одразу й придатна для автоматизації.
Через це tsc --noEmit кладуть окремим npm-скриптом (часто його звуть typecheck) і роблять обов'язковим кроком у CI — поруч із самим прогоном тестів, а не замість нього. Один запускає код, інший стереже типи; жоден не покриває роботу другого.
@types/*: звідки беруться декларації
TypeScript перевіряє типи не лише твого коду, а й усього, що ти імпортуєш. Щоб знати сигнатуру expect чи page.click, йому потрібні декларації типів — файли .d.ts. Є два шляхи, звідки вони беруться.
Сучасні бібліотеки постачають типи прямо в собі: Playwright, Axios, Zod уже містять свої .d.ts у пакеті, тож import — і все типізовано, нічого доставляти не треба. Але чимало пакетів (особливо старіших чи спершу написаних на чистому JS) типів у собі не мають. Для них існує DefinitelyTyped — величезний спільнотний репозиторій декларацій, які публікуються в npm під скоупом @types. Тому й з'являються @types/node, @types/lodash тощо: сам пакет — окремо, його типи — окремо, у devDependencies.
Для тестового проєкту на Node майже завжди обов'язковий один пакет — @types/node. Він типізує середовище Node: process, fs, path, Buffer, __dirname, таймери. Якщо його немає, TS не знає, що таке process, і видасть Cannot find name 'process' — хоча код чудово виконається, бо в рантаймі Node усе це є.
За замовчуванням TypeScript автоматично підхоплює всі @types-пакети з node_modules/@types як глобальні. Опція types у tsconfig дозволяє звузити цей список, якщо чужі глобальні типи починають конфліктувати.
ts-node і tsx: запуск TS напряму
Node.js сам по собі виконує JavaScript, не TypeScript. Щоб запустити .ts-файл без окремого кроку компіляції в .js, потрібен посередник, який компілює на льоту.
ts-node — класичний варіант: реєструє хук у Node, який компілює TS у пам'яті перед виконанням. У типовому режимі перевіряє типи (тому може бути повільним на великих проєктах); має transpileOnly для швидкості.
tsx — новіша й швидша альтернатива на esbuild: транспілює миттєво, типи не перевіряє ніколи, дружніша до ES-модулів. Запуск простий:
npx tsx scripts/seed.ts
Де це реально треба в тестовому проєкті? Не для самих тестів — Playwright і Jest мають власну транспіляцію й ts-node/tsx для запуску тестів не потребують. А от для допоміжних скриптів на TS — засіяти тестові дані, дернути API, згенерувати фікстури — зручно мати можливість запустити .ts-файл однією командою через tsx, не заводячи окремий білд. Правило вибору просте: якщо потрібна швидкість і не потрібна перевірка типів у момент запуску — tsx; якщо історично проєкт на ts-node (як типовий CodeceptJS) — лишаєш ts-node. Перевірку типів у будь-якому разі забезпечує окремий tsc --noEmit, а не рантайм.
Типові помилки компіляції та як їх читати
Повідомлення tsc мають сталий формат:
tests/login.spec.ts(42,7): error TS2345: Argument of type 'string | null'
is not assignable to parameter of type 'string'.
Читається зліва направо: файл(рядок,колонка) — куди стрибати; TSxxxx — код помилки, за яким її легко загуглити й зрозуміти категорію; далі — суть неспівпадіння. У фразах «is not assignable» напрямок важливий: TS каже, що перше не влазить у друге, а не навпаки.
Найчастіші коди й що вони насправді означають:
TS2307: Cannot find module 'X' or its corresponding type declarations— виглядає як «пакет не встановлено», а насправді причин три: або справді немає пакета, або пакет є, але без типів і не встановлено@types/X, або поламанийpaths/moduleResolution. Спочатку перевір, чи є@types, і лише потім підозрюй сам імпорт.TS2339: Property 'X' does not exist on type 'Y'— виглядає як «в об'єкті немає поля», а насправді часто це друкарська помилка в назві або занадто вузький тип (TS вивів не той тип, який ти уявляєш). Наведись у редакторі й подивись, який тип TS насправді бачить.TS7006: Parameter 'x' implicitly has an 'any' type— це не про твою логіку, а проnoImplicitAnyзіstrict: параметр без анотації. Лікується анотацією типу, а не вимиканням суворості.TS2531 / TS18047 / TS18048: Object is possibly 'null'/'undefined'— прямий наслідокstrictNullChecks. Виглядає як прискіпування, а насправді TS показує реальну діру: значення може бути відсутнім, і в рантаймі ти отримав биCannot read properties of null. Це саме той баг, який TS ловить наперед.TS2304: Cannot find name 'process'(абоdescribe,expect) — виглядає як помилка коду, а насправді бракує декларацій оточення: дляprocess—@types/node, для глобальнихdescribe/test— правильно підключений тестовий фреймворк уtypes/lib.
Головне при читанні помилок TS: код (TSxxxx) називає категорію, повідомлення — конкретне неспівпадіння, а координати ведуть до місця. Не поспішай гасити помилку через any чи @ts-ignore — здебільшого вона вказує на справжню невідповідність, яку інакше довелося б ловити впав-тестом. Про те, як читати вже рантаймові стек-трейси (а не помилки компіляції), — окрема глава «Помилки та винятки: try/catch і стек-трейси».
Підсумок
tsconfig.jsonчитають кілька споживачів по-різному: редактор іtscперевіряють типи, а ранер їх лише зрізає й виконує код. «Редактор червоний, тест зелений» — це не суперечність, а ця різниця.- Ранери (Playwright, tsx, часто ts-node) працюють у transpile-only: зелений прогін не гарантує типобезпеки. Гарантію дає окремий
tsc --noEmit, і його місце — у CI поруч із тестами, а не замість них. strict: true— базовий дефолт; без нього TypeScript майже нічого не перевіряє.target/moduleставлять під своє середовище Node, а не на дефолт.paths— compile-time-аліаси; у рантаймі їх мусить розв'язувати бандлер,tsconfig-pathsабо loader ранера, інакше будеCannot find module.- Типи залежностей беруться або з самого пакета, або з
@types/*(DefinitelyTyped);@types/nodeу Node-проєкті потрібен майже завжди.
Що питають на співбесіді
- «У вас тести на TypeScript — коли перевіряються типи?» Інтерв'юер перевіряє, чи розумієш ти, що ранер типи не перевіряє. Сильна відповідь: ранер транспілює й виконує без перевірки, а типи стереже окремий
tsc --noEmitу пайплайні. - «Навіщо
tsc --noEmit, якщо тести й так проходять?» Дивляться, чи не плутаєш ти «код виконався» з «типи коректні». Суть:--noEmit— це чиста перевірка типів без генерації файлів; вона ловить те, що ранер пропускає. - «Що робить
strictі чому його вмикають?» Очікують, що назвеш кілька прапорців сімейства (strictNullChecks,noImplicitAny) і поясниш, що без ньогоanyзнецінює перевірку. - «Що таке
@types/nodeі навіщо він?» Перевірка базового розуміння, звідки TS бере декларації для бібліотек без власних типів. - «Налаштував
paths, а Node каже Cannot find module — чому?» Класична пастка: аліаси compile-time, у рантаймі їх ніхто не переписав. Дивляться, чи розрізняєш ти перевірку типів і виконання.
Джерела
- TSConfig Reference — усі опції
compilerOptions— офіційний довідник (strict,target,module,paths,noEmit). - TypeScript Handbook: Modules та Compiler Options — модульна система й перевірка типів.
- Playwright: TypeScript — як Playwright транспілює TS без перевірки типів і як увімкнути окремий
tsc. - DefinitelyTyped — репозиторій
@types— звідки беруться спільнотні декларації. - tsx та ts-node — офіційна документація рантаймів для запуску
.ts.
Що таке tsconfig.json і навіщо він у тестовому проєкті?
Це файл налаштувань компілятора TypeScript, який лежить у корені проєкту й каже: «звідси починається TS-код, і трактувати його треба ось так». Правила в ньому відповідають на два різні питання — як перевіряти типи і як перетворювати TS-синтаксис на звичайний JavaScript, що реально виконає рушій. Для тестового проєкту це не декоративний файл: від нього залежить, чи ловитиме TypeScript помилки до запуску, під яку версію Node зводиться код і чи знайдуться аліаси шляхів. Заглядають у нього нечасто, а дарма — саме тут криється відповідь на більшість «чому редактор бачить одне, а ранер робить інше».
Чому редактор підсвічує помилку типів червоним, а npx playwright test проходить зелено?
Бо це роблять два різні споживачі того самого tsconfig, і в них різні задачі. Редактор через мовний сервіс tsserver проганяє перевірку типів і малює червоне підкреслення, але код не запускає — це суто статичний аналіз. А ранер бере той самий файл, викидає з нього анотації типів і виконує результат; типи його не обходять узагалі. Тому тест із явною помилкою типів спокійно стартує й може пройти — логіка спрацювала, а невідповідність типів просто поїхала в рантайм разом зі зрізаними анотаціями. Це не суперечність і не баг, а різниця між «перевірити» і «виконати».
Хто взагалі читає tsconfig і чи однаково вони його трактують?
Читачів щонайменше троє, і кожен робить свій висновок. Перший — редактор (tsserver): показує помилки типів, нічого не виконує. Другий — ранер (Playwright, tsx, ts-node): зрізає типи й виконує код, а перевірку типів ігнорує. Третій — tsc --noEmit: проганяє повну перевірку типів і повертає ненульовий код виходу при помилці, але код теж не виконує. Один файл, один конфіг — три поведінки. Розуміння, що ці ролі не перекриваються, і є ключем до всієї теми: «зелений тест» гарантує роботу логіки, а не типобезпеку.
Що робить strict і чому його радять вмикати?
strict — це головний перемикач суворості, і насправді не одна опція, а ціле сімейство: strict: true разом вмикає strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization та інші. Найважливіший із них — strictNullChecks: він змушує TypeScript відрізняти User від User | null і ловить звернення до можливо відсутнього значення ще до запуску. Без strict мова деградує до «JavaScript із коментарями»: тип any розповзається всюди, і перевірка перестає щось гарантувати. Для нового тестового проєкту strict: true — розумний дефолт, бо саме заради цих перевірок TypeScript і брали.
Навіщо задавати target і module, якщо є значення за замовчуванням?
target — це версія ECMAScript, до якої TS зводить синтаксис (ES2021, ES2022, ESNext), і вона ж визначає, який набір вбудованих типів доступний. module каже, у яку модульну систему перетворяться import/export — у require(...) (CommonJS) чи в нативні ES-модулі. Покладатися на дефолти тут не варто: історично вони дуже консервативні й можуть без потреби зводити код до стародавнього ES, який Node і так виконує нативно. У тестовому проєкті код запускає Node, а не старий браузер, тому target ставлять під свою версію Node, а module/moduleResolution — під те, як проєкт реально резолвить модулі (nodenext, bundler тощо).
Що таке transpile-only і чому більшість ранерів працює саме так?
Transpile-only означає «прибрати з файлу типи й виконати, не перевіряючи їх». Технічно зрізати анотації — майже суто синтаксична операція: пройтися файлом і викинути : string, interface, as User; це швидко й можна робити пофайлово, не будуючи карту типів усього проєкту. А повна перевірка типів дорога — щоб зрозуміти сумісність двох типів, компілятор мусить розгорнути весь граф типів, дженерики та імпорти. Тому інструменти, заточені під швидкість старту, свідомо пропускають цей крок. Практичний наслідок жорсткий: зелений прогін не доводить типобезпеки, він доводить лише те, що код синтаксично коректний і логіка відпрацювала.
Навіщо tsc --noEmit, якщо тести й так проходять?
Бо «тест пройшов» і «типи коректні» — не одне й те саме, а ранер стереже тільки перше. tsc --noEmit запускає повний компілятор TypeScript у режимі «нічого не генеруй, лише перевір»: він читає tsconfig, збирає весь проєкт, проганяє повну перевірку типів і повертає ненульовий код виходу на першій же помилці, при цьому не засмічуючи диск згенерованими .js. Це та сама перевірка, що дає редактор, але одразу для всього проєкту й придатна для автоматизації. Тому її кладуть окремим npm-скриптом (зазвичай typecheck) і роблять обов'язковим кроком CI — поруч із прогоном тестів, а не замість нього.
Звідки TypeScript бере типи для бібліотек, які ти імпортуєш?
TypeScript перевіряє типи не лише твого коду, а й усього імпортованого, тож йому потрібні декларації — файли .d.ts. Шляхів два. Сучасні бібліотеки постачають типи прямо в пакеті: Playwright, Axios, Zod уже містять свої .d.ts, тож після import усе типізовано без зайвих рухів. Але багато пакетів (особливо старих або спершу написаних на чистому JS) типів у собі не мають — для них існує DefinitelyTyped, спільнотний репозиторій декларацій, які публікуються в npm під скоупом @types. Звідси й беруться @types/lodash, @types/node тощо: сам пакет окремо, його типи окремо, у devDependencies.
Навіщо в Node-проєкті майже завжди потрібен @types/node?
Бо він типізує саме середовище Node — process, fs, path, Buffer, __dirname, таймери. Node усе це надає в рантаймі, але TypeScript про нього нічого не знає, доки не побачить відповідні декларації. Без @types/node навіть звичайне звернення до process.env дасть помилку Cannot find name 'process', хоча код чудово виконається. Тому в тестовому проєкті на Node цей пакет ставлять у devDependencies майже завжди — це базова умова, щоб перевірка типів бачила глобали оточення, а не сипала помилками на кожну системну функцію.
Налаштував paths, а Node каже Cannot find module. Чому?
Бо paths — це виключно compile-time-функція. Компілятор і редактор розуміють @pages/login і резолвлять його для перевірки типів, але при транспіляції TypeScript не переписує цей аліас — у згенерованому коді лишається буквально @pages/login. Node такого шляху не знає: для нього це не відносний шлях і не пакет у node_modules, тож у рантаймі — Cannot find module '@pages/login'. Аліаси мусить розв'язати ще хтось: бандлер, окремий резолвер tsconfig-paths або loader усередині tsx/ts-node. Playwright, наприклад, поважає paths зі свого tsconfig при запуску тестів, а голий node над довільним скриптом — ні. Тому перш ніж заводити красиві аліаси, варто переконатися, що спосіб запуску вміє їх розв'язувати.
Чим ts-node відрізняється від tsx і коли який брати?
Обидва дозволяють запустити .ts-файл без окремого кроку компіляції в .js, але роблять це по-різному. ts-node — класика: реєструє хук у Node й компілює TS у пам'яті перед виконанням; у типовому режимі він перевіряє типи (тому буває повільним), але часто його вмикають у transpileOnly або через swc заради швидкості — і тоді перевірка зникає. tsx — новіша й швидша альтернатива на esbuild: транспілює миттєво, типи не перевіряє ніколи, дружніша до ES-модулів. Правило вибору просте: потрібна швидкість і не потрібна перевірка при запуску — tsx; проєкт історично на ts-node (як типовий CodeceptJS) — лишаєш ts-node. Перевірку типів у будь-якому разі забезпечує окремий tsc --noEmit, а не рантайм.
Де в тестовому проєкті реально потрібен tsx або ts-node?
Не для самих тестів — Playwright і Jest мають власну транспіляцію й окремий рантайм для запуску .ts-тестів не потребують. Реальна потреба — це допоміжні скрипти на TypeScript: засіяти тестові дані, дернути API перед прогоном, згенерувати фікстури. Для них зручно мати змогу запустити .ts-файл однією командою (npx tsx scripts/seed.ts), не заводячи окремий білд у JavaScript. Тобто ранери тестів приходять зі своїм механізмом, а tsx/ts-node закривають нішу разових і сервісних скриптів навколо сюїти.
Як правильно читати помилку компілятора на кшталт error TS2345?
У повідомлення tsc сталий формат: файл(рядок,колонка): error TSxxxx: суть. Читається зліва направо — координати кажуть, куди стрибати; код TSxxxx називає категорію помилки й легко гуглиться; далі йде конкретне неспівпадіння. У формулюваннях типу «is not assignable» важливий напрямок: TypeScript каже, що перше не влазить у друге, а не навпаки. Тобто не варто гасити помилку навмання через any чи @ts-ignore — спершу зрозумій за кодом категорію, за повідомленням конкретику, а за координатами дійди до місця. Здебільшого помилка вказує на справжню невідповідність, яку інакше довелося б ловити впалим тестом.
TS2307: Cannot find module 'X'. Які насправді причини?
Виглядає як «пакет не встановлено», але причин три, і треба перебрати їх по черзі. Перша — пакета справді немає, тоді допоможе npm install. Друга — пакет є, але без власних типів, і не встановлено @types/X; тоді сам код виконається, а перевірка типів спіткнеться, бо їй бракує декларацій. Третя — зламані paths чи moduleResolution, тобто TypeScript просто не туди шукає модуль. Правильний порядок дій: спершу перевірити, чи потрібні @types, і лише потім підозрювати сам імпорт або конфіг резолвінгу. Одразу лізти встановлювати пакет, який уже стоїть, — типова хибна реакція на цю помилку.
Побачив TS7006: Parameter 'x' implicitly has an 'any' type. Що це?
Це не про твою логіку, а про опцію noImplicitAny, яка входить у strict: у параметра немає анотації типу, і TypeScript відмовляється мовчки підставляти any. Лікується воно анотацією — вказати тип параметра, — а не вимиканням суворості. Спокуса поставити x: any чи прибрати strict знецінює всю перевірку: саме noImplicitAny не дає типам тихо провалюватися в any і втрачати гарантії. Тому правильна реакція — дати параметру чесний тип, і помилка зникне разом із потенційним багом, який ховався за нетипізованим значенням.
Object is possibly 'null'. Це прискіпування TypeScript чи реальна проблема?
Майже завжди реальна. Помилки TS2531/TS18047/TS18048 — прямий наслідок strictNullChecks: TypeScript бачить, що значення може бути null чи undefined, і показує це до запуску. Виглядає як педантизм, а насправді це та сама діра, через яку в рантаймі прилетіло б Cannot read properties of null. Тобто мова ловить наперед баг, який інакше проявився б впалим тестом або падінням на проді. Правильна реакція — не глушити помилку, а обробити відсутність: перевірити на null, звузити тип, дати дефолт. @ts-ignore тут просто ховає проблему до першого невдалого запуску.
Що дають skipLibCheck та isolatedModules і навіщо вони в тестах?
skipLibCheck пропускає перевірку типів усередині .d.ts залежностей — це помітно пришвидшує tsc і прибирає чужий шум із декларацій сторонніх бібліотек, який ти все одно не правитимеш. isolatedModules забороняє конструкції, які не можна коректно транспілювати пофайлово, і тим узгоджує проєкт зі швидкими транспіляторами на кшталт esbuild чи swc (на esbuild побудований tsx). У тестовому проєкті обидві опції практичні: перша прискорює крок typecheck, друга страхує від ситуації, коли компілятор і ранер розходяться в тому, що взагалі можна перетворити. Разом вони роблять збірку і швидшою, і передбачуванішою.
Як побудувати пайплайн, щоб «зелений тест» і «типи коректні» означали одне й те саме?
Ключ — додати перевірку типів окремим кроком, бо ранер її не робить. У package.json заводять скрипт typecheck, який запускає npx tsc --noEmit, і ставлять його в CI поруч із прогоном тестів — не замість нього, а як самостійний обов'язковий крок. Тоді один процес виконує код і перевіряє логіку, а другий стереже типи; жоден не покриває роботу другого. Без цього кроку TypeScript у тестах лишається підказками редактора, а не гарантією: помилка типів проходить крізь ранер непоміченою. Із ним «білд зелений» уже чесно означає і «тести пройшли», і «типи сходяться».
Три кейси, де tsconfig вирішує, чи означає «зелений тест» те саме, що «код без помилок типів»: як відтворити розбіжність «редактор червоний, ранер зелений» і зловити її окремою перевіркою, як зібрати мінімальний конфіг для Playwright-проєкту з кроком typecheck у CI, і чому аліас paths живе в редакторі, але вмирає в рантаймі. Скрізь — що дивитися й чому.
Кейс 1. «Редактор червоний, тест зелений» — і хто це ловить
Класична пастка новачка: у тесті звернення до можливо відсутнього значення, редактор підкреслив його червоним, а прогін проходить. Ось мінімальний файл, що це відтворює:
import { test, expect } from '@playwright/test';
test('токен вставляється в заголовок', async ({ request }) => {
// process.env.API_TOKEN має тип string | undefined
const token: string = process.env.API_TOKEN; // редактор: error TS2322
const res = await request.get('/api/me', {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
});
Редактор показує TS2322: Type 'string | undefined' is not assignable to type 'string', бо змінна оточення може бути невизначеною. А npx playwright test цю саму помилку не бачить: ранер зрізав анотацію : string, виконав код, і якщо API_TOKEN у середовищі є — тест позеленів. Помилка нікуди не поділася, вона просто поїхала в рантайм; варто прибрати змінну з оточення — і замість чесного TS2322 отримаєш Bearer undefined та 401, яке доведеться розкопувати руками.
Зловити невідповідність до запуску можна тим самим інструментом, що й редактор, але для всього проєкту одразу:
npx tsc --noEmit
tests/auth.spec.ts(5,9): error TS2322: Type 'string | undefined'
is not assignable to type 'string'.
Що дивитися й чому:
- Ранер і редактор дивляться на різне. Прогін перевіряє, що логіка відпрацювала; червоне в редакторі — що типи сходяться. Це два незалежні твердження, і зелений тест не покриває друге.
tsc --noEmit— це редактор для всього проєкту. Він читаєtsconfig, збирає всі файли, проганяє повну перевірку типів і падає з ненульовим кодом на першій же помилці, нічого не генеруючи на диск.- Правильна реакція — не
any, а обробка відсутності. Тут чесний фікс — перевірити змінну (if (!token) throw ...) або звузити тип, а не заглушитиTS2322кастом доany. Помилка вказує на реальну діру, а не прискіпується.
Кейс 2. Мінімальний tsconfig для Playwright + typecheck у CI
Мета — щоб «білд зелений» чесно означав і «тести пройшли», і «типи сходяться». Для цього потрібні розумний конфіг і окремий крок перевірки.
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"types": ["node", "@playwright/test"],
"noEmit": true
},
"include": ["tests/**/*.ts", "playwright.config.ts"]
}
Перевірку типів вішають окремим скриптом — ранер її не робить:
{
"scripts": {
"test": "playwright test",
"typecheck": "tsc --noEmit"
}
}
І обидва кроки стають обов'язковими в пайплайні — поруч, а не замість:
# .github/workflows/ci.yml (фрагмент)
- run: npm run typecheck # стереже типи
- run: npm run test # виконує код
Що дивитися й чому:
strict: true— база. Без ньогоanyрозповзається, іtsc --noEmitперестає ловити те, заради чого його ставили. Це не «для суворих», а мінімум, щоб перевірка мала сенс.target/module— під своє Node, не на дефолт. ТутES2022іnodenext: код виконує сучасний Node, зводити його до стародавнього ES немає сенсу. Дефолт історично консервативний, тож його задають явно.types: ["node", ...]звужує глобали. За замовчуванням TS підхоплює всі@typesяк глобальні; явний список прибирає чужий шум і випадкові конфлікти декларацій.@types/nodeтут обов'язковий — без ньогоprocess/fsневідомі.typecheck— окремий крок CI. Якщо його немає, TypeScript у тестах лишається підказками редактора: помилка типів пройде крізь ранер і проявиться вже впалим тестом або багом на проді.
Кейс 3. paths працює в редакторі й ламається під node
Аліаси шляхів роблять імпорти охайними, але приховують пастку. Конфіг виглядає бездоганно:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@pages/*": ["src/pages/*"],
"@fixtures/*": ["src/fixtures/*"]
}
}
}
import { LoginPage } from '@pages/login';
У редакторі перехід за @pages/login працює, tsc --noEmit проходить. Але разовий скрипт через голий node (після ручної компіляції) падає:
Error: Cannot find module '@pages/login'
Причина: paths — виключно compile-time-функція. При транспіляції TypeScript не переписує аліас, у виводі лишається буквально @pages/login, а Node такого шляху не знає — це ні відносний шлях, ні пакет у node_modules. Розв'язати аліас має ще хтось:
| Спосіб запуску | Чи розв'язує paths |
|---|---|
| Playwright Test | Так — поважає paths зі свого tsconfig |
tsx / ts-node з відповідним резолвером | Так — через loader/tsconfig-paths |
| Бандлер (esbuild, Vite, webpack) | Так — переписує аліас у білді |
Голий node над скомпільованим .js | Ні — потрібен tsconfig-paths або відносні шляхи |
Що дивитися й чому:
Cannot find module '@alias'— це не про TypeScript. Перевірка типів пройшла, зламалося виконання: аліас не розв'язав ніхто на рантаймі. Симптом упізнаваний саме тим, що редактор іtscмовчать, а падає лише запуск.- Ранер тестів зазвичай рятує. Playwright резолвить
pathsсам, тому в самих тестах проблема часто не видно — вона вилазить на сервісних скриптах, які запускають черезnode. - Перш ніж заводити аліаси — перевір спосіб запуску. Якщо якийсь скрипт піде через голий
node, або додайtsconfig-paths, або лишай для нього відносні шляхи. Красивий імпорт не вартий години полювання заCannot find moduleна проді.
tsconfig і його споживачі
- Розумію, що
tsconfig.jsonу корені означає «звідси коренева тека TS-проєкту» і задає правила перевірки та транспіляції. - Знаю трьох споживачів конфігу й що вони роблять по-різному: редактор (
tsserver) перевіряє типи, ранер зрізає їх і виконує код,tsc --noEmitперевіряє без виконання. - Можу пояснити, чому «редактор червоний, а тест зелений» — це не суперечність, а різниця між перевіркою типів і виконанням; перевірка й транспіляція — дві окремі операції, які TS робить незалежно.
Ключові опції compilerOptions
- Знаю, що
strict— це сімейство прапорців (strictNullChecks,noImplicitAny,strictFunctionTypes, …), а не одна опція, і чому без нього перевірка майже нічого не гарантує. - Можу пояснити, за що відповідають
targetіmodule, і чому їх ставлять під своє середовище Node, а не лишають на консервативний дефолт. - Розумію призначення
esModuleInterop,skipLibCheck,isolatedModules,types,noEmitу тестовому проєкті: зокрема, чимskipLibCheckпришвидшуєtscі чомуisolatedModulesузгоджує проєкт зі швидкими транспіляторами (esbuild, swc).
Пастка paths
- Розумію, що
paths— це compile-time-аліаси: компілятор і редактор їх резолвлять, а у згенерованому коді вони лишаються нерозв'язаними. - Можу пояснити, чому
@pages/loginдаєCannot find moduleпід голимnode, і хто мусить розв'язати аліас: бандлер,tsconfig-pathsабо loader ранера (Playwright резолвитьpathsзі свогоtsconfigсам, довільний скрипт черезnode— ні).
Перевірка типів vs виконання
- Розумію, що таке transpile-only і чому зрізати типи дешево, а перевірити їх дорого.
- Знаю, що Playwright і
tsxтипи не перевіряють ніколи, аts-nodeперевіряє лише позаtranspileOnly/swc. - Можу пояснити, чому зелений прогін не доводить типобезпеки, а лише те, що логіка спрацювала.
- Знаю, що
tsc --noEmit— це чиста перевірка типів без генерації файлів, і що її місце — окремим кроком CI поруч із тестами, а не замість них.
@types і декларації
- Розумію, що TS перевіряє типи й імпортованого коду, тож йому потрібні декларації
.d.ts, і знаю два їх джерела: вбудовані в пакет (Playwright, Axios, Zod) чи спільнотні через@types/*(DefinitelyTyped). - Можу пояснити, навіщо в Node-проєкті майже завжди потрібен
@types/nodeі що ламається без нього (Cannot find name 'process'). - Знаю, що TS автоматично підхоплює всі
@typesяк глобальні, і що опціяtypesдозволяє звузити цей список при конфліктах.
Запуск TS і читання помилок
- Можу пояснити різницю між
ts-nodeіtsxі коли який брати для допоміжних скриптів. - Розумію, що ранери тестів мають власну транспіляцію, тож
tsx/ts-nodeпотрібні не для тестів, а для сервісних скриптів (сід, фікстури, дзвінки в API). - Вмію читати формат помилки
файл(рядок,колонка): error TSxxxxі розумію, що напрямок «is not assignable» важливий. - Знаю значення частих кодів:
TS2307(модуль/типи),TS2339(немає поля),TS7006(implicit any),TS2531/TS18047/TS18048(possibly null),TS2304(немає імені оточення). - Розумію, чому не варто гасити помилку типів через
anyчи@ts-ignore— здебільшого вона вказує на справжню невідповідність.
Ти написав тест із явною помилкою типів, редактор підсвітив її червоним, а npx playwright test пройшов зелено. Що сталося?
Питання
Що таке tsconfig.json і за що він відповідає?