JavaScript і TypeScript
Мова фронтенду зсередини: типи й порівняння, область видимості та this, модель виконання з event loop і промісами, TypeScript від основ до просунутих типів. Бекенд-розробника на fullstack-співбесіді перевіряють саме тут: там, де JavaScript поводиться не так, як C#.
Які типи є в JavaScript і чим == відрізняється від ===?
Section titled “Які типи є в JavaScript і чим == відрізняється від ===?”Що це
- Примітиви —
string,number,bigint,boolean,undefined,null,symbol. Незмінні, порівнюються за значенням. - Об’єкти — усе інше:
{}, масиви, функції,Date. Змінна зберігає посилання, як змінна класу в C#.
Як користуватися
- Тип перевіряють через
typeof:typeof 'a'—'string'. Дві дивини:typeof null—'object', а для функції —'function'. Масив перевіряють черезArray.isArray. - Порівнюють через
===: мають збігтися і тип, і значення.==перед порівнянням приводить типи, тому0 == ''—true. - Falsy-значення:
false,0,'',null,undefined,NaN. Усе інше вifвважається істиною, зокрема[]і'0'.
Типова помилка
Писати count || 10 для значення за замовчуванням. При count = 0 вийде 10; потрібно count ?? 10.
Як влаштовано
===— строга рівність: різні типи одразу даютьfalse, об’єкти рівні, лише якщо це одне посилання.==— нестрога:nullіundefinedрівні лише одне одному, рядок іbooleanприводяться до числа, об’єкт — до примітиву черезvalueOfабоtoString.NaNне дорівнює нічому, навіть собі. Перевірка —Number.isNaN(x).
Коли що обирати
| Задача | Що писати |
|---|---|
| Порівняти значення | === |
Перевірити на null і undefined разом |
x == null |
Замовчування лише для null і undefined |
x ?? def |
| Замовчування для будь-якого falsy | x || def |
Знайти NaN у масиві |
includes, а не indexOf |
Пастки
typeof null === 'object'. Перевірка «це об’єкт» —typeof x === 'object' && x !== null.- Глобальний
isNaN. Він спершу приводить аргумент до числа:isNaN('abc')—true. ПотрібенNumber.isNaN. - Порівняння об’єктів.
{ id: 1 } === { id: 1 }—false. НіEquals, ні перевантаження операторів, як у C#, немає: глибоке порівняння пишуть самі або беруть із бібліотеки. - Дробові числа.
number— 64-бітний double, якdoubleу C#:0.1 + 0.2 !== 0.3. Гроші рахують у копійках або передають рядком.
Правило
Завжди ===, крім x == null; для замовчувань — ??. Правило ESLint eqeqeq закріплює це в коді.
Під капотом
==за специфікацією — алгоритм IsLooselyEqual: за різних типів операнди крок за кроком приводяться до числа або примітиву. Об’єкт стає примітивом черезSymbol.toPrimitive,valueOfабоtoString, тому[] == false:[]→''→0.typeof null === 'object'— помилка першої реалізації мови: тег типу «об’єкт» був нулем, як і вказівникnull. Її залишили заради сумісності.- Видів рівності чотири:
==,===,Object.is(SameValue) і SameValueZero. Останнім користуютьсяincludes,MapіSet, томуNaNу них знаходиться.
Ціна
number— IEEE 754 double. Цілі точні доNumber.MAX_SAFE_INTEGER(2^53 − 1).long-ідентифікатор із C#, більший за поріг,JSON.parseмовчки округлить:9007199254740993стане9007199254740992.bigintточний, але не змішується зnumber(1n + 1—TypeError), аJSON.stringifyйого не серіалізує.
Компроміси
- Великі
longіdecimalбекенд віддає рядком: у System.Text.Json —JsonNumberHandling.WriteAsStringабо конвертер для конкретних полів. Альтернатива — домовитися, що ID не перевищують 2^53. - Ключі
MapіSetпорівнюються за посиланням. Об’єкт-ключ шукають за тим самим екземпляром або переходять на рядковий ключ.
Як перевірити в продакшені
- ESLint:
eqeqeq, а для TypeScript —@typescript-eslint/prefer-nullish-coalescingіstrict-boolean-expressions. - Втрату точності ID ловить інтеграційний тест з ідентифікатором, більшим за 2^53: у відповіді він має повернутися без змін.
Підсумок: рівність у JavaScript — кілька алгоритмів із різною семантикою, а найдорожча пастка на стику з .NET — точність number.
console.log(typeof null); // object — історична помилкаconsole.log(typeof undefined); // undefinedconsole.log(typeof (() => {})); // function
console.log(0 == ''); // true: '' приводиться до 0console.log(0 === ''); // false: різні типиconsole.log(null == undefined); // trueconsole.log(null == 0); // falseconsole.log([] == false); // true: [] → '' → 0, false → 0console.log(Boolean([])); // true: будь-який об'єкт truthy
console.log(NaN === NaN); // falseconsole.log(Number.isNaN(NaN)); // trueconsole.log([NaN].includes(NaN)); // trueconsole.log([NaN].indexOf(NaN)); // -1
const qty = 0;console.log(qty || 10); // 10: 0 — falsyconsole.log(qty ?? 10); // 0: 0 — не null і не undefined
const a = { id: 1 };const b = a;console.log(a === b); // true: одне посиланняconsole.log(a === { id: 1 }); // false: інший об'єкт- Чим
undefinedвідрізняється відnull? —undefined— «значення не задано»: неініціалізована змінна, відсутня властивість;null— явне «порожньо». - Коли
==допустимий? —x == null: одна перевірка наnullіundefined. - Чим
Object.isвідрізняється від===? —Object.is(NaN, NaN)—true,Object.is(0, -0)—false. - Чому
typeof null—'object'? — Помилка першої реалізації, залишена заради сумісності. - Як передати
longіз C# без втрати точності? — Рядком;numberточний лише до 2^53 − 1.
Як працюють область видимості, замикання та this?
Section titled “Як працюють область видимості, замикання та this?”Що це
- Область видимості — частина коду, де змінна доступна.
letіconstвидно в блоці{ },var— у всій функції. - Замикання — функція разом зі змінними, доступними під час її створення. Вона бачить їх і після виходу із зовнішньої функції.
this— об’єкт «перед крапкою» під час виклику: уuser.hello()цеuser.
Як користуватися
- Змінні — через
const, черезlet— якщо значення змінюється.varу новому коді не потрібен. - Замикання зберігає приватний стан:
makeCounter()повертає функцію з власнимcount. - Колбеки — стрілковими функціями: власного
thisу них немає, вони беруть його ззовні.
Типова помилка
Передати метод як колбек: setTimeout(user.hello, 100). Функцію викличуть не через user, і this вказуватиме не на нього. Правильно — setTimeout(() => user.hello(), 100).
Як влаштовано
| Властивість | var |
let, const |
|---|---|---|
| Область | функція | блок |
| До оголошення | undefined |
ReferenceError (TDZ) |
| Повторне оголошення | можна | помилка |
Правила this за пріоритетом:
new F()— новий об’єкт.f.call(obj),f.apply(obj, args),f.bind(obj)— явно заданий об’єкт.bindповертає нову функцію із закріпленимthis.obj.f()— об’єкт перед крапкою.f()—undefinedу strict mode (модулі та класи завжди strict), інакше глобальний об’єкт.
Стрілкова функція в цих правилах не бере участі: this вона бере з оточення під час створення, і call чи bind його не змінюють.
Пастки
- Цикл із
var. Усі колбеки ділять одну змінну й бачать її останнє значення.letуforстворює нову змінну на кожну ітерацію. У C# так само поводитьсяfor, аforeachіз C# 5 створює нову змінну на ітерацію. - Відрив методу.
const f = obj.methodвтрачаєobj. У C# делегатAction a = obj.Methodзапам’ятовує об’єкт, у JavaScript — ні. - Стрілка як метод об’єкта. Стрілкова функція в літералі об’єкта бере
thisззовні, а не сам об’єкт.
Правило
const за замовчуванням, let — якщо потрібне перевизначення. Колбеки — стрілковими функціями; метод, який віддають назовні, — полем-стрілкою або через bind.
Під капотом
- За специфікацією функція зберігає посилання на лексичне оточення (Environment Record) місця створення. Пошук змінної йде ланцюжком оточень назовні.
- Рушій кладе захоплені змінні в об’єкт контексту в купі. У V8 замикання однієї функції ділять один контекст: довгоживучий колбек тримає й змінні, потрібні лише сусідньому замиканню.
this— не змінна оточення, а параметр виклику. Стрілкові функції берутьthis,argumentsіsuperіз зовнішньої функції, тому їх не можна викликати черезnew.
Ціна
- Метод класу — одна функція на прототипі. Поле-стрілка
handle = () => {}— окрема функція в кожному екземплярі: пам’ять на кожен об’єкт, і нащадок не викличе її черезsuper.handle(). - Замикання утримує все, що захопило. Невідписаний обробник події або таймер тримає компонент і його дані — частий витік пам’яті в SPA.
Компроміси
bindабо поле-стрілка: пам’ять на екземпляр проти надійногоthis, коли метод віддають назовні.- Замикання дає справжню приватність без класів. Приватні поля
#xдають її й класу, без окремої функції на екземпляр.
Як перевірити в продакшені
- Chrome DevTools → Memory → Heap snapshot: порівняти два знімки й пройти ланцюжком Retainers. Записи
(closure)іsystem / Contextпоказують, яке замикання тримає пам’ять. - ESLint
no-var,prefer-constіno-loop-funcприбирають пастки зvarі циклами.
Підсумок: замикання — оточення в купі, яке живе, доки жива функція, а this задає виклик; обидва — часті джерела витоків і помилок у колбеках.
console.log(v); // undefined: var піднято, він дорівнює undefinedvar v = 1;try { console.log(l);} catch (e) { console.log(e.name); // ReferenceError: l у TDZ}let l = 2;
const withVar = [];for (var i = 0; i < 3; i++) withVar.push(() => i);console.log(withVar.map(f => f())); // [ 3, 3, 3 ]: одна i на всю функцію
const withLet = [];for (let j = 0; j < 3; j++) withLet.push(() => j);console.log(withLet.map(f => f())); // [ 0, 1, 2 ]: нова j на кожну ітерацію
function makeCounter() { let count = 0; // живе, доки жива повернена функція return () => ++count;}const next = makeCounter();next();console.log(next()); // 2class Counter { count = 0; inc() { return ++this.count; } // метод на прототипі incArrow = () => ++this.count; // поле: власна функція в кожного об'єкта}
const c = new Counter();const inc = c.inc; // метод відірвано від об'єктаconst incArrow = c.incArrow;
try { inc(); // this === undefined: класи в strict mode} catch (e) { console.log(e.name); // TypeError}console.log(incArrow()); // 1: this узято з конструктораconsole.log(inc.call(c)); // 2: this задано явноconsole.log(inc.bind(c)()); // 3: bind повертає нову функціюconsole.log(inc.apply(c, [])); // 4: як call, аргументи масивом- Що виведе цикл із
varіsetTimeout, і як це виправити? — Три однакові значення; замінитиvarнаletабо передати значення параметром. - Чи можна змінити
thisстрілкової функції черезbind? — Ні,bind,callіapplyйого ігнорують. - Чим
callвідрізняється відapply? — Лише передачею аргументів: списком або масивом. - Що таке TDZ? — Ділянка від початку блоку до оголошення
letабоconst, де звертання даєReferenceError. - Чому обробник з
addEventListenerвтрачаєthisоб’єкта? — Його викликає браузер, іthisбуде елементом, на якому спрацювала подія.
Як влаштовані event loop, проміси та async/await у JavaScript?
Section titled “Як влаштовані event loop, проміси та async/await у JavaScript?”Що це
- Один потік. Поки працює функція, нічого іншого не відбувається.
- Event loop — цикл, який бере наступну задачу з черги, коли поточний код завершився: клік, таймер, відповідь сервера.
- Проміс — об’єкт, який пізніше стане виконаним (fulfilled) з результатом або відхиленим (rejected) з помилкою.
Як користуватися
- Функція з
asyncзавжди повертає проміс. awaitусередині неї чекає на проміс і віддає його результат; потік тим часом вільний.- Помилки ловлять звичайним
try/catchнавколоawait. - Незалежні запити запускають паралельно:
await Promise.all([loadUser(), loadOrders()]).
Типова помилка
Викликати async-функцію без await і без .catch. Помилка всередині неї не потрапить у ваш try/catch і стане необробленою.
Як влаштовано
- Синхронний код виконується на стеку викликів до кінця.
- Коли стек порожній, виконуються всі мікрозадачі: колбеки
then,catchіfinally, продовження післяawait,queueMicrotask. Мікрозадачі, додані дорогою, виконуються в цьому ж проході. - Браузер може відмалювати кадр.
- Event loop бере одну задачу з черги:
setTimeout, подію, повідомлення мережі. Цикл повторюється.
Код async-функції до першого await виконується синхронно, решта стає мікрозадачею.
Коли що обирати
| Метод | Результат |
|---|---|
Promise.all |
масив результатів; відхиляється за першої помилки |
Promise.allSettled |
статуси всіх промісів, ніколи не відхиляється |
Promise.race |
перший завершений — успіх або помилка |
Promise.any |
перший успішний; якщо впали всі — AggregateError |
Пастки
Promise.allне скасовує решту. Один проміс відхилився — інші запити й далі працюють, їхній результат просто губиться.forEachзasync-колбеком.forEachне чекає на проміси. Послідовно —for...ofзawait, паралельно —Promise.allзmap.- Зайва послідовність. Два незалежні
awaitпоспіль чекають суму часу, а не максимум.
Правило
Кожен проміс або чекають через await, або повертають, або явно обробляють через .catch. Незалежні операції — через Promise.all.
Під капотом
- Специфікація ECMAScript описує реакції промісів як завдання (jobs), а браузер і Node.js кладуть їх у чергу мікрозадач. У Node.js є ще черга
process.nextTick, вона виконується раніше за проміси, і фази циклу libuv: таймери, введення-виведення,setImmediate. async-функція — машина станів, як у C#. Але продовження післяawaitзавжди йде через чергу мікрозадач, навіть якщо проміс уже виконано; у C#awaitзавершеноїTaskпродовжує синхронно.- Введення-виведення виконують браузер і libuv у своїх потоках, у JavaScript повертаються лише колбеки.
Ціна
- Усе — в одному потоці. Обчислення на 200 мс заморожує інтерфейс або, на Node.js, усі запити сервера. CPU-роботу виносять у Web Worker або
worker_threads. - Блокувань немає, але гонки між
awaitє: відповіді двох запитів пошуку приходять в іншому порядку, і стара перезаписує нову.
Компроміси
- Скасування в промісу немає. Його передають через
AbortControllerіAbortSignal— аналогCancellationToken:fetch(url, { signal }),AbortSignal.timeout(5000). Promise.all— fail-fast, зазвичай це те, що потрібно.allSettled— коли частковий результат кращий за жодний.
Як перевірити в продакшені
- Unhandled rejection: у браузері подію
unhandledrejectionнадсилають у моніторинг помилок, Node.js з версії 15 за замовчуванням падає. - Довгі задачі видно в DevTools → Performance. Затримку event loop у Node.js вимірює
perf_hooks.monitorEventLoopDelay(). - Правило
@typescript-eslint/no-floating-promisesзнаходить проміси без обробки.
Підсумок: один потік і порядок «задача → усі мікрозадачі → кадр» дають просту модель без блокувань, але будь-яка довга синхронна робота зупиняє все.
console.log('A'); // 1setTimeout(() => console.log('timeout'), 0); // 7: задачаPromise.resolve().then(() => console.log('then')); // 4: мікрозадачаqueueMicrotask(() => console.log('microtask')); // 5: мікрозадача
async function run() { console.log('run start'); // 2: до await — синхронно await null; console.log('after await'); // 6: продовження — мікрозадача}run();console.log('B'); // 3Числа в коментарях — порядок виведення.
// ES-модуль: await на верхньому рівніconst ok = (value, ms) => new Promise(resolve => setTimeout(() => resolve(value), ms));const fail = (message, ms) => new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms));
try { await Promise.all([ok(1, 50), fail('boom', 10)]);} catch (e) { console.log(e.message); // boom: через 10 мс, на ok(1) не чекаємо}
const settled = await Promise.allSettled([ok(1, 10), fail('boom', 10)]);console.log(settled.map(r => r.status)); // [ 'fulfilled', 'rejected' ]
console.log(await Promise.any([fail('a', 5), ok(2, 20)])); // 2: перший успішнийawait Promise.race([fail('a', 5), ok(2, 20)]) .catch(e => console.log(e.message)); // a: перший завершений
try { await Promise.any([fail('a', 5), fail('b', 5)]);} catch (e) { console.log(e.constructor.name, e.errors.length); // AggregateError 2}- Що виконається раніше:
setTimeout(fn, 0)чиPromise.resolve().then(fn)? —then: мікрозадачі виконуються до наступної задачі. - Чим
Promise.allвідрізняється відTask.WhenAll? —Promise.allвідхиляється одразу за першої помилки,WhenAllдочікується всіх задач. - Як скасувати
fetch? — ПередатиsignalвідAbortControllerі викликатиabort(). - Чому нескінченний ланцюжок мікрозадач вішає сторінку? — Черга мікрозадач вигрібається повністю, до відмальовування й наступної задачі.
- Чи потрібні в JavaScript блокування? — Ні, але гонки між
awaitможливі; їх закривають скасуванням застарілих запитів.
Що дає TypeScript і як ним правильно користуватися?
Section titled “Що дає TypeScript і як ним правильно користуватися?”Що це
TypeScript — надбудова над JavaScript: та сама мова плюс анотації типів. Компілятор tsc перевіряє типи й видає JavaScript без них.
Навіщо
- Помилки знаходяться під час збирання: одрук у властивості, забутий аргумент, можливий
undefined. - IDE знає типи: автодоповнення й безпечне перейменування.
Як користуватися
- Описуйте форму даних через
interfaceабоtype:interface User { id: number; name: string }. - Параметри функцій анотуйте, а тип змінної компілятор часто виводить сам:
const n = 5—number. - Тримайте
"strict": trueуtsconfig.json; із TypeScript 6.0 це поведінка за замовчуванням.
Типова помилка
Ставити any, коли компілятор свариться. any вимикає перевірку, і помилка переїжджає в рантайм. Для значення невідомого типу є unknown.
Як влаштовано
- Структурна типізація. Об’єкт підходить під
interface Point, якщо в нього єxіy. У C# типізація номінальна: два класи з однаковими полями несумісні. - Звуження. Компілятор стежить за перевірками: після
typeof x === 'string'зміннаxмає типstring. Працюютьtypeof,instanceof,in,===і type guard — функція з результатомx is User. - Generics схожі на C#:
function first<T>(items: T[]), обмеження черезextends. Але в рантайміTнемає:new T()іtypeof Tнеможливі.
Коли що обирати
| Задача | Вибір |
|---|---|
| Форма об’єкта, контракт класу | interface, розширюється через extends |
| Union, кортеж, обчислюваний тип | type |
| Значення невідомого типу | unknown і звуження |
| Міграція з JavaScript, іншого виходу немає | any, точково |
Пастки
- Перевірка зайвих властивостей працює лише для об’єктного літерала. Той самий об’єкт, переданий через змінну, пройде.
as— не приведення.data as Userу рантаймі нічого не перевіряє.strict— ще не все. Доступ за індексомarr[0]даєT, а неT | undefined, доки не ввімкненоnoUncheckedIndexedAccess.
Правило
strict завжди, unknown замість any, as — лише там, де ви знаєте більше за компілятор, і з коментарем чому.
Під капотом
tsc— перевірка типів плюс виведення JavaScript. Збирачі esbuild і SWC, а також Node.js 22.18+ лише вирізають типи, не перевіряючи їх, томуtsc --noEmit— окремий крок CI.- Вирізати можна не все:
enum,namespaceз кодом і parameter properties породжують JavaScript. ПрапорецьerasableSyntaxOnly(TypeScript 5.8) забороняє такий синтаксис. - Типів у рантаймі немає, тому DI в Angular і NestJS спирається на класи як значення. Інтерфейс не може бути токеном: для нього заводять
InjectionTokenабо рядковий токен.
Ціна
- Відповідь API типізована рівно настільки, наскільки їй повірили:
res.json()повертаєPromise<any>. Помилка формату проявиться далеко від місця отримання. - Перевірка схемою в рантаймі коштує CPU і розміру бандла, зате помилка падає на межі зі зрозумілим шляхом до поля.
Компроміси
- Zod (або Valibot, ArkType): схема — єдине джерело правди, тип виводиться через
z.infer. Генерація типів з OpenAPI дає контракт без перевірки в рантаймі. - Структурна типізація плутає
UserIdіOrderId, якщо обидва —string. Branded-типstring & { readonly __brand: 'UserId' }повертає номінальність. - TypeScript 7 (липень 2026) — компілятор, переписаний на Go: перевірка приблизно в 10 разів швидша.
Як перевірити в продакшені
- У CI:
tsc --noEmitі ESLint з@typescript-eslint/no-explicit-anyта правиламиno-unsafe-*. - Помилки валідації схеми пишуть у лог зі шляхом до поля: вони першими показують, що бекенд змінив контракт.
Підсумок: TypeScript перевіряє код, а не дані, тому на межі із зовнішнім світом потрібна перевірка в рантаймі.
interface Point { x: number; y: number }class Vector { x = 3; y = 4; } // про Point нічого не знає
function length(p: Point): number { return Math.hypot(p.x, p.y);}console.log(length(new Vector())); // 5: підходить за формоюconsole.log(length({ x: 6, y: 8 })); // 10
function describe(value: unknown): string { if (typeof value === 'string') return value.toUpperCase(); // value: string if (Array.isArray(value)) return `array of ${value.length}`; // value: any[] return 'other';}console.log(describe('ts')); // TSconsole.log(describe([1, 2])); // array of 2
function first<T>(items: T[]): T | undefined { return items[0];}const n = first([1, 2, 3]); // n: number | undefinedconsole.log(n?.toFixed(1)); // 1.0// npm i zodimport { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });type User = z.infer<typeof User>; // { id: number; name: string }
const raw: unknown = JSON.parse('{"id":"42","name":"Ann"}');
const trusted = raw as User; // компілюється: as нічого не перевіряєconsole.log(typeof trusted.id); // string
const result = User.safeParse(raw);if (result.success) { console.log(result.data.id);} else { console.log(result.error.issues[0].path); // [ 'id' ]}- Чим
typeвідрізняється відinterface? —interfaceрозширюється й зливається за повторного оголошення;typeописує union, кортежі й обчислювані типи. - Чим
unknownвідрізняється відany? — Зunknownнічого не можна зробити без звуження, аanyвимикає перевірку й розповзається кодом. - Що входить до
strict? —strictNullChecks,noImplicitAny,strictFunctionTypes,strictPropertyInitialization,useUnknownInCatchVariablesта інші. - Чому не можна написати
new T()? — Типи стираються; конструктор передають параметромctor: new () => T. - Як зробити номінальний тип для ID? — Branded-тип: перетин
stringз унікальним полем-міткою.
Просунуті типи TypeScript: discriminated unions, utility, mapped і conditional types — коли вони потрібні?
Section titled “Просунуті типи TypeScript: discriminated unions, utility, mapped і conditional types — коли вони потрібні?”Що це
- Union
A | B— значення одного з типів. Discriminated union — union об’єктів зі спільним полем, яке в кожному варіанті має своє літеральне значення:status: 'draft',status: 'paid'. - Utility types — вбудовані «функції над типами»: приймають тип і повертають новий.
Як користуватися
- Перевірте поле-мітку в
ifабоswitch— усередині гілки TypeScript знає точний варіант: у гілці'paid'доступнеpaidAt. Partial<T>— усі поля необов’язкові: тіло PATCH-запиту.Pick<T, 'a' | 'b'>іOmit<T, 'id'>— вибрати або прибрати поля: форма створення безid.Record<K, V>— словник:Record<string, number>.
Типова помилка
Описувати стан необов’язковими полями: { status: string; paidAt?: string }. Компілятор не знає, коли paidAt заповнене, і перевірки розповзаються кодом.
Як влаштовано
keyof T— union імен властивостей,T[K]— тип властивості за ключем,typeof value— тип значення.- Mapped type
{ [K in keyof T]: ... }перебирає ключі й будує новий об’єктний тип. Модифікатори?,readonlyі-?додають або знімають ознаки,asперейменовує ключі. Так влаштованіPartial,Readonly,Record. - Conditional type
T extends U ? X : Yобирає тип за умовою.infer Rвитягує частину типу:T extends Promise<infer R> ? R : T. На цьому побудованіReturnType,Awaited,Exclude. - Перевірка через
never. У гілціdefaultпісля всіхcaseтип звужено доnever. Рядокconst x: never = orderперестає компілюватися, щойно з’являється новий варіант. satisfies(TypeScript 4.9) перевіряє, що значення підходить під тип, але залишає виведений тип:keyof typeof routes—'home' | 'user', а неstring. У проєктах на Angular 13 (TypeScript 4.4–4.6) його ще не було.
Пастки
Omit<User, 'idd'>з одруком компілюється: ключіOmitне перевіряються, аPickїх перевіряє.Partialнеглибокий: вкладені об’єкти залишаються обов’язковими.- Conditional type над union розподіляється за варіантами:
ToArray<string | number>даєstring[] | number[]. Щоб цього не було, обидві сторони обгортають у кортеж:[T] extends [U].
Правило
Стани — discriminated union з перевіркою через never; похідні форми — utility types від одного вихідного типу; власні mapped і conditional types — коли вбудованих не вистачає.
Під капотом
- Ці типи існують лише для компілятора й не породжують коду. Кожне застосування generic-типу — інстанціювання, яке компілятор обчислює й кешує.
- Рекурсія має межу: за надто глибокого інстанціювання — помилка TS2589 «Type instantiation is excessively deep and possibly infinite».
Ціна
Складні generic-типи сповільнюють перевірку й підказки IDE, а повідомлення про помилки розгортаються на кілька екранів.
Компроміси
- Генерація з OpenAPI. ASP.NET Core (
Microsoft.AspNetCore.OpenApi, .NET 9+) видає документ,openapi-typescriptгенерує з нього типи, а NSwag або Orval — одразу клієнт. Типи не розходяться з бекендом, але точні настільки, наскільки описано API: без[ProducesResponseType]абоTypedResultsсхема відповіді буде неповною. - Згенеровані проти власних. Згенеровані DTO — транспорт. Доменні стани на клієнті краще описати discriminated union і відобразити в них відповідь.
- Виразність проти читабельності. Тип на 20 рядків з conditional і mapped types зрозумілий лише авторові. Публічні контракти модулів тримають простими.
Як перевірити в продакшені
tsc --extendedDiagnosticsпоказує кількість інстанціювань і час перевірки,--generateTrace— які типи дорогі.- Тести типів:
@ts-expect-errorабоexpectTypeOfу Vitest фіксують поведінку власних утиліт. - CI перегенеровує типи з OpenAPI й падає на
git diff --exit-code, якщо контракт змінився без фронтенду.
Підсумок: просунуті типи окуповуються на межах — у станах і контрактах API, — а платять за них часом компіляції та читабельністю.
type Order = | { status: 'draft' } | { status: 'paid'; paidAt: string } | { status: 'shipped'; trackingNo: string };
function label(order: Order): string { switch (order.status) { case 'draft': return 'Draft'; case 'paid': return `Paid at ${order.paidAt}`; // order звужено до варіанта 'paid' case 'shipped': return `Track ${order.trackingNo}`; default: { const unreachable: never = order; // новий статус — помилка компіляції тут return unreachable; } }}
console.log(label({ status: 'paid', paidAt: '2026-10-10' })); // Paid at 2026-10-10console.log(label({ status: 'shipped', trackingNo: 'UA1' })); // Track UA1interface User { id: number; name: string; email: string }
type UserPatch = Partial<Omit<User, 'id'>>; // { name?: string; email?: string }type UserById = Record<number, User>; // словник id → User
type Getters<T> = { [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];};type NameGetter = Getters<Pick<User, 'name'>>; // { getName: () => string }
type Unwrap<T> = T extends Promise<infer R> ? R : T;type Loaded = Unwrap<Promise<User[]>>; // User[]
const routes = { home: '/', user: '/users/:id',} satisfies Record<string, string>;type RouteName = keyof typeof routes; // 'home' | 'user'
const patch: UserPatch = { name: 'Ann' };console.log(Object.keys(patch)); // [ 'name' ]console.log(routes.user); // /users/:idЗ анотацією const routes: Record<string, string> тип RouteName був би просто string.
- Чим union рядків відрізняється від
enum? — Union стирається без сліду,enumпороджує об’єкт у JavaScript; у новому коді частіше беруть union. - Навіщо
satisfies, якщо є анотація типу? — Анотація розширює тип до оголошеного,satisfiesперевіряє й зберігає точне виведення. - Що робить
infer? — Оголошує змінну типу всередині умови conditional type і витягує в неї частину типу. - Як гарантувати, що
switchобробив усі варіанти? — Присвоїти значення змінній типуneverу гілціdefault. - Звідки брати типи DTO бекенду? — Генерувати з OpenAPI в CI, а не писати руками.