JavaScript и TypeScript
Язык фронтенда изнутри: типы и сравнение, область видимости и this, модель выполнения с event loop и промисами, TypeScript от основ до продвинутых типов. Бэкенд-разработчика на fullstack-собеседовании проверяют именно здесь: там, где JavaScript ведёт себя не так, как C#.
Какие типы есть в JavaScript и чем == отличается от ===?
Заголовок раздела «Какие типы есть в 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?
Заголовок раздела «Как работают область видимости, замыкания и 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?
Заголовок раздела «Как устроены 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 и как им пользоваться правильно?
Заголовок раздела «Что даёт 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 — когда они нужны?
Заголовок раздела «Продвинутые типы 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, а не писать руками.