Branded Memento: типизация приватного состояния без компромиссов
Классический паттерн Снимок (Memento) часто сводится к поверхностной инкапсуляции: TypeScript
Branded type как физический барьер
Memento — не контейнер данных, а заглушка с уникальным символом:
Снаружи невозможно прочитать
WeakMap: приватность без утечек
Реальное состояние хранится в
Сборщик мусора автоматически удаляет запись, когда снимок больше не используется. Никаких утечек.
Практический совет и типичная ошибка
Сериализовать такой Memento напрямую в JSON нельзя — WeakMap не итерируется. Решение: если нужна де/сериализация, замени WeakMap на
*Типичная ошибка:* пытаться сделать Memento как простой объект с полями. Даже
Вывод: Branded types в паре с WeakMap превращают TypeScript из системы типов в инструмент настоящей инкапсуляции, где приватность гарантируется на уровне исполнения, а не только компиляции.
Классический паттерн Снимок (Memento) часто сводится к поверхностной инкапсуляции: TypeScript
private ломается через as any или Object.keys, а runtime-доступ к состоянию остается открытым. В production — будь то state management в SPA, логирование в Node.js-сервисах или rollback в API-клиентах — это приводит к неявным мутациям и багам. Решение лежит в комбинации branded типов и WeakMap.Branded type как физический барьер
Memento — не контейнер данных, а заглушка с уникальным символом:
declare const MementoBrand: unique symbol;
interface Memento {
readonly [MementoBrand]: void;
}
Снаружи невозможно прочитать
[MementoBrand] — он просто не существует в runtime. Любая попытка as any не даст доступа к реальному состоянию, так как его там нет.WeakMap: приватность без утечек
Реальное состояние хранится в
WeakMap<Memento, State>. Ключ — сам объект Memento, который генерируется внутри класса:const stateMap = new WeakMap<Memento, { balance: number }>();
class BankAccount {
private balance = 0;
createSnapshot(): Memento {
const memento = {} as Memento;
stateMap.set(memento, { balance: this.balance });
return memento;
}
restore(memento: Memento) {
const state = stateMap.get(memento);
if (state) this.balance = state.balance;
}
}Сборщик мусора автоматически удаляет запись, когда снимок больше не используется. Никаких утечек.
Практический совет и типичная ошибка
Сериализовать такой Memento напрямую в JSON нельзя — WeakMap не итерируется. Решение: если нужна де/сериализация, замени WeakMap на
Map<string, State> и генерируй UUID для снимка. Теряешь автогарбаж, но получаешь поддержку persistence.*Типичная ошибка:* пытаться сделать Memento как простой объект с полями. Даже
readonly в TS не защитит от Object.assign или деструктуризации в модульных тестах.Вывод: Branded types в паре с WeakMap превращают TypeScript из системы типов в инструмент настоящей инкапсуляции, где приватность гарантируется на уровне исполнения, а не только компиляции.
Buffer и Uint8Array: грабли, которые стоят часов дебага
Одна из самых коварных ловушек в Node.js — смешивание
Проблема: slice vs subarray
Допустим, ты парсишь TCP-поток с zero-copy, работая с подмассивами:
Ключевые грабли
*
*
* Обратная конвертация (передача
Правильный подход: DataView
Для кросс-платформенного zero-copy парсинга используй
Практические советы
* Не используй
* Для кросс-платформенного парсинга (браузер + Node) — только
*
* Если нужен гибридный парсер, оберни все в
Предупреждение
Неявное смешивание типов — главный источник багов производительности и логики. Zero-copy теряется, как только ты вызываешь
Вывод: Для надежного zero-copy парсинга бинарных протоколов используй
Одна из самых коварных ловушек в Node.js — смешивание
Buffer и Uint8Array при парсинге бинарных протоколов. На первый взгляд они взаимозаменяемы: Buffer наследует от Uint8Array. Но под капотом — разные контракты, и "тихая" конвертация данных может убить zero-copy или вызвать TypeError.Проблема: slice vs subarray
Допустим, ты парсишь TCP-поток с zero-copy, работая с подмассивами:
// Ошибка: buffer.slice() копирует данные в Node <20
function parsePacket(raw: Buffer): number {
const header = raw.slice(0, 4);
return header.readUInt32BE(); // работает, но копия
}
// Правильно: subarray() делает zero-copy view
function parsePacket(raw: Buffer): number {
const header = raw.subarray(0, 4); // Uint8Array
// TypeError: header.readUInt32BE is not a function
}
Ключевые грабли
*
Buffer.prototype.slice() до Node 20 копирует данные, убивая zero-copy.*
Buffer.prototype.subarray() возвращает Uint8Array, а не Buffer. Методы .readUInt* недоступны.* Обратная конвертация (передача
Uint8Array в Buffer API) — тихая, но с сюрпризом: методы Buffer не работают.Правильный подход: DataView
Для кросс-платформенного zero-copy парсинга используй
DataView поверх ArrayBuffer:class PacketParser {
private view: DataView;
constructor(private buffer: ArrayBuffer | Buffer) {
this.view = new DataView(
buffer instanceof Buffer ? buffer.buffer : buffer
);
}
readUint32(offset: number): number {
return this.view.getUint32(offset, false); // big-endian
}
readSlice(offset: number, length: number): ArrayBuffer {
return this.buffer.slice(offset, offset + length);
}
}
// Zero-copy: subarray, затем DataView
const raw = Buffer.from([0, 0, 0, 42]);
const firstPacket = raw.subarray(0, 4); // Uint8Array
const parser = new PacketParser(firstPacket.buffer);
console.log(parser.readUint32(0)); // 42Практические советы
* Не используй
Buffer.read* при zero-copy — они не работают с Uint8Array.* Для кросс-платформенного парсинга (браузер + Node) — только
DataView.*
Buffer.concat возвращает Buffer и копирует данные — явная конвертация.* Если нужен гибридный парсер, оберни все в
new Uint8Array(bufferOrView) и используй TextDecoder или DataView.Предупреждение
Неявное смешивание типов — главный источник багов производительности и логики. Zero-copy теряется, как только ты вызываешь
.slice() или передаешь Buffer в API браузера (например, WebSocket.send).Вывод: Для надежного zero-copy парсинга бинарных протоколов используй
DataView поверх subarray — это единственный способ избежать тихих конвертаций и сохранить производительность в production.Вложенные Promise-конструкторы и скрытые утечки памяти: почему
На code review я часто вижу этот паттерн от middle и senior разработчиков. В production — в Node.js сервисах, подписках на EventEmitter, SPA с кастомными колбэками — это фабрика висячих промисов, которые никогда не завершаются. Основная ошибка: передача
Почему промис «не зарезолвится»?
Когда вы пишете
Пример:
Типизировать resolve/reject: не просто
Простая сигнатура не показывает, что resolve должен вызываться ровно один раз. Правильнее — замыкание с внутренним состоянием или явный Deferred.
Лучшее решение —
Deferred даёт явное управление жизнью промиса — вы контролируете, когда и как его завершить. Это trade-off: больше кода, но меньше утечек.
Как избежать висячих промисов
Совет: если сохраняете resolve в объекте (подписка на класс), удаляйте ссылку после завершения через
Предупреждение: передавать resolve как колбэк в сторонние подписки без привязки к жизненному циклу — плохая идея. Используйте Deferred с гарантией однократного вызова и очисткой. Утечки через промисы трудно ловить, но код должен быть чистым по памяти.
Вывод: Ручное управление resolve/reject в JavaScript требует явного контроля жизненного цикла — без Deferred или
new Promise(resolve => executor(resolve)) опаснее, чем кажется, и как типизировать ручное управление resolve/rejectНа code review я часто вижу этот паттерн от middle и senior разработчиков. В production — в Node.js сервисах, подписках на EventEmitter, SPA с кастомными колбэками — это фабрика висячих промисов, которые никогда не завершаются. Основная ошибка: передача
resolve как колбэка без обёртки.Почему промис «не зарезолвится»?
Когда вы пишете
new Promise(resolve => executor(resolve)), внутренний executor сохраняет ссылку на resolve как колбэк. Если executor — подписка на EventEmitter или долгоживущий объект, resolve живёт в замыкании, пока жив объект. Промис ждёт вызова, которого не будет — утечка памяти гарантирована. GC не соберёт его из-за замкнутой ссылки.Пример:
function subscribeToEvents(emitter) {
return new Promise(resolve => {
emitter.on('data', resolve); // resolve висит, пока emitter жив
// emitter никогда не эмитит — промис висит вечно
});
}Типизировать resolve/reject: не просто
(value?) => voidПростая сигнатура не показывает, что resolve должен вызываться ровно один раз. Правильнее — замыкание с внутренним состоянием или явный Deferred.
Лучшее решение —
Promise.withResolvers() (ES2024) или кастомный Deferred:interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: Deferred<T>['resolve'];
let reject!: Deferred<T>['reject'];
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}Deferred даёт явное управление жизнью промиса — вы контролируете, когда и как его завершить. Это trade-off: больше кода, но меньше утечек.
Как избежать висячих промисов
Совет: если сохраняете resolve в объекте (подписка на класс), удаляйте ссылку после завершения через
.finally(). Иначе накопите кучу функций, которые GC не вычистит.const deferred = createDeferred<Data>();
emitter.on('data', deferred.resolve);
deferred.promise.finally(() => {
emitter.off('data', deferred.resolve);
});
Предупреждение: передавать resolve как колбэк в сторонние подписки без привязки к жизненному циклу — плохая идея. Используйте Deferred с гарантией однократного вызова и очисткой. Утечки через промисы трудно ловить, но код должен быть чистым по памяти.
Вывод: Ручное управление resolve/reject в JavaScript требует явного контроля жизненного цикла — без Deferred или
Promise.withResolvers() вы рискуете утечками памяти, которые не видны в тестах, но проявляются в production при долгоживущих подписках.Кеш для Intl — не опция, а необходимость при heavy load интернационализации
Тема performance в интернационализации часто недооценивается. В production — будь то SPA с динамической сменой локали, Node.js API, отдающий отформатированные цены тысячам пользователей, или SSR с рендерингом сотен дат на страницу — каждый вызов
Кешируйте форматтеры, не конструкторы
Создавать форматтер на каждый вызов — путь к тормозам. Решение: храните готовые экземпляры в
Типизируйте опции явно, а не строковыми literals
В TypeScript
Это ловит опечатки на этапе компиляции. Практический совет: для
Предварительно проверяйте кастомные локали
Не считайте кеширование Intl микрооптимизацией. Это базовый паттерн для надежного DX в heavy-load системах.
Вывод: Всегда кешируйте экземпляры
Тема performance в интернационализации часто недооценивается. В production — будь то SPA с динамической сменой локали, Node.js API, отдающий отформатированные цены тысячам пользователей, или SSR с рендерингом сотен дат на страницу — каждый вызов
new Intl.DateTimeFormat() или Intl.NumberFormat() без кеша нагружает процессор парсингом CLDR-данных и построением форматтера. Типичная ошибка: писать форматтинг прямо внутри рендера или цикла, думая, что это "микрооптимизация".Кешируйте форматтеры, не конструкторы
Создавать форматтер на каждый вызов — путь к тормозам. Решение: храните готовые экземпляры в
Map или WeakMap, ключом делайте строку JSON.stringify({ locale, ...options }). Но будьте внимательны: если опции меняются часто (например, динамический timeZone), кеш может не дать выгоды. Всегда включайте изменчивые параметры в ключ.Типизируйте опции явно, а не строковыми literals
В TypeScript
DateTimeFormatOptions — это широкий тип, легко передать 'number' вместо 'numeric'. Ошибка всплывет в runtime. Создайте строгий union:type DatePart = 'numeric' | '2-digit';
type StrictOptions = {
year?: DatePart;
month?: DatePart;
day?: DatePart;
};
Это ловит опечатки на этапе компиляции. Практический совет: для
Intl.NumberFormat используйте NumberFormatOptions с кастомными константами валют — это предотвращает случайное переключение формата.Предварительно проверяйте кастомные локали
Intl.DateTimeFormat.supportedLocalesOf() — не просто метод, а must-have перед вызовом конструктора. Без него передача неподдерживаемой локали генерирует RangeError. Для Intl.NumberFormat кешируйте по ключу locale:currency: когда пользователь переключает язык на лету, это предотвращает фризы в UI. В Node.js проверьте, не используете ли вы small-icu — без full ICU кастомные локали приведут к багам за день до релиза.Не считайте кеширование Intl микрооптимизацией. Это базовый паттерн для надежного DX в heavy-load системах.
Вывод: Всегда кешируйте экземпляры
Intl.XXXFormat по стабильному ключу и типизируйте опции — это дает стабильную производительность и предотвращает runtime-ошибки в production.❤1
Deep dirty structuredClone: лимиты, нюансы сериализации и хаки для клонирования неподдерживаемых типов в production сценариях
В production часто нужно глубокое копирование объектов, особенно при работе с состоянием в SPA, API клиентами или кэшированием в Node.js сервисах. Многие разработчики полагаются на structuredClone как на серебряную пулю, но забывают о его строгих ограничениях, которые могут привести к потере данных или багам в runtime.
Что structuredClone копирует, а что нет
Он успешно обрабатывает объекты, массивы, Map, Set, Date, RegExp, Blob, File и ArrayBuffer, включая циклические ссылки. Но есть черный список: функции, классы с методами, Symbol (как ключи и значения), DOM-элементы, а также Error объекты, которые клонируются как простые объекты с полями message и stack, но без прототипа. В production это проблема, например, при сериализации конфигов с колбэками или при работе с кастомными классами в shared libraries.
Хаки для продакшна
Для функций нужна ручная обвязка:
Этот подход грубый: замыкания привязаны к оригинальному скоупу, а не к копии. Полная изоляция через new Function или eval опасна и подходит только для ограниченных сценариев (например, серверные конфиги с проверенными функциями). Для DOM-элементов используйте cloneNode(true). Для Error восстанавливайте прототип:
Но это меняет поведение — оригинальный Error мог иметь кастомные свойства, которые не сериализуются.
Типичная ошибка
Нельзя использовать structuredClone для клонирования объектов с кастомными прототипами, например, экземпляров классов с методами, или для сохранения ссылочной идентичности. Все копии становятся независимыми плоскими объектами. Symbol-ключи полностью теряются. В бандлинге или дизайн-системах это ведет к неожиданным багам, когда копия не имеет ожидаемого поведения или ломает инварианты модуля.
Вывод: structuredClone надежен только для изолированных данных без функций, Symbol и кастомных прототипов — для production-сценариев с артефактами runtime всегда делайте ручную обвязку и документируйте границы клонирования.
В production часто нужно глубокое копирование объектов, особенно при работе с состоянием в SPA, API клиентами или кэшированием в Node.js сервисах. Многие разработчики полагаются на structuredClone как на серебряную пулю, но забывают о его строгих ограничениях, которые могут привести к потере данных или багам в runtime.
Что structuredClone копирует, а что нет
Он успешно обрабатывает объекты, массивы, Map, Set, Date, RegExp, Blob, File и ArrayBuffer, включая циклические ссылки. Но есть черный список: функции, классы с методами, Symbol (как ключи и значения), DOM-элементы, а также Error объекты, которые клонируются как простые объекты с полями message и stack, но без прототипа. В production это проблема, например, при сериализации конфигов с колбэками или при работе с кастомными классами в shared libraries.
Хаки для продакшна
Для функций нужна ручная обвязка:
function cloneWithFunctions(obj: Record<string, unknown>) {
const clone = structuredClone(obj);
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'function') {
clone[key] = obj[key];
}
}
return clone;
}Этот подход грубый: замыкания привязаны к оригинальному скоупу, а не к копии. Полная изоляция через new Function или eval опасна и подходит только для ограниченных сценариев (например, серверные конфиги с проверенными функциями). Для DOM-элементов используйте cloneNode(true). Для Error восстанавливайте прототип:
function cloneError(err: Error): Error {
return Object.assign(Object.create(Error.prototype), err, {
message: err.message,
name: err.name,
stack: err.stack
});
}Но это меняет поведение — оригинальный Error мог иметь кастомные свойства, которые не сериализуются.
Типичная ошибка
Нельзя использовать structuredClone для клонирования объектов с кастомными прототипами, например, экземпляров классов с методами, или для сохранения ссылочной идентичности. Все копии становятся независимыми плоскими объектами. Symbol-ключи полностью теряются. В бандлинге или дизайн-системах это ведет к неожиданным багам, когда копия не имеет ожидаемого поведения или ломает инварианты модуля.
Вывод: structuredClone надежен только для изолированных данных без функций, Symbol и кастомных прототипов — для production-сценариев с артефактами runtime всегда делайте ручную обвязку и документируйте границы клонирования.
❤2
Как объявить Proxy типобезопасным: рефлексивные типы-обёртки для API, логирования и моков без потери IntelliSense
Взяли Proxy, обернули API-клиент для логирования запросов. Вроде всё ок, пока не попытались вызвать метод с аргументами. IntelliSense молчит, типы потерялись, а в рантайме — undefined. Знакомо? Проблема в том, что
Решение: пересоздание типов через mapped types
Не просто
Теперь
Где это реально выручает
* Логирование вызовов API без изменения типов ответов.
* Моки для тестов — подменяешь метод, а интерфейс остаётся.
* Валидация на
Ловушки и компромиссы
Если нужен доступ к динамическим полям вроде
Вывод: Без пересоздания типов Proxy остаётся чёрным ящиком; с mapped types — нормальный инструмент без потери подсказок, но с осознанием границ динамических полей.
Взяли Proxy, обернули API-клиент для логирования запросов. Вроде всё ок, пока не попытались вызвать метод с аргументами. IntelliSense молчит, типы потерялись, а в рантайме — undefined. Знакомо? Проблема в том, что
new Proxy(target, handler) возвращает тип target как есть, но handler.get возвращает any, потому что тип prop — string | symbol. Компилятор не знает, что именно ты достаёшь, и подсказки пропадают.Решение: пересоздание типов через mapped types
Не просто
T, а структура, где каждый метод сохраняет свою сигнатуру:type TypedProxy<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => R
: T[K];
};Теперь
createProxy возвращает объект, где IntelliSense видит те же аргументы и возврат, что у оригинала. В handler.get мы сами вызываем obj[prop](...args) — да, тут any, но наружу типы уже прокинуты нормально.Где это реально выручает
* Логирование вызовов API без изменения типов ответов.
* Моки для тестов — подменяешь метод, а интерфейс остаётся.
* Валидация на
set с проверкой типов через conditional types.Ловушки и компромиссы
Если нужен доступ к динамическим полям вроде
proxy[userInput], mapped types не помогут — придётся добавить index signature с [key: string]: unknown. IntelliSense для такого не заработает, это компромисс. Ещё одна ловушка: T extends object пропустит примитив. Лучше уточнять через T extends Record<string, any>, иначе прокси упадёт на числах или строках.Вывод: Без пересоздания типов Proxy остаётся чёрным ящиком; с mapped types — нормальный инструмент без потери подсказок, но с осознанием границ динамических полей.
React Server Components в Next.js App Router: грабли, о которые споткнулись многие
Даже на middle+ проектах RSC в Next.js подкидывают сюрпризы, которые ломают гидрацию, порождают race conditions в параллельных роутах и сбивают с толку типизацию Server Actions. Разберём три production-кейса, где интуиция подводит.
Гидрация RSC: клиент оборачивает сервер
Когда клиентский компонент оборачивает серверный, Next.js сначала рендерит всё на сервере, затем гидратирует на клиенте. Ошибка: если серверный компонент использует
Race conditions в параллельных роутах
Parallel Routes выглядят мощно, но на практике легко получить состояние гонки. Когда два слота —
Типизация Server Actions
Server Actions не наследуют типы при передаче в клиентские компоненты — TypeScript думает, что всё ок, а на деле тип съезжает, потенциально вызывая runtime-ошибку. Решение: вручную объявляй дженерик-тип:
Типобезопасно, но требует явной аннотации — не доверяй автоматическому выводу.
Вывод: RSC в Next.js — мощный инструмент, но границы между клиентом и сервером, синхронизация кэша в параллельных роутах и явная типизация Server Actions требуют строгого инженерного контроля, иначе production ловит рассогласование данных.
Даже на middle+ проектах RSC в Next.js подкидывают сюрпризы, которые ломают гидрацию, порождают race conditions в параллельных роутах и сбивают с толку типизацию Server Actions. Разберём три production-кейса, где интуиция подводит.
Гидрация RSC: клиент оборачивает сервер
Когда клиентский компонент оборачивает серверный, Next.js сначала рендерит всё на сервере, затем гидратирует на клиенте. Ошибка: если серверный компонент использует
cookies() или headers(), а клиентский в это же время делает revalidation, данные расходятся — сервер видит один стейт, клиент другой. Фикс: добавь Suspense на границе между клиентским и серверным кодом или вынеси состояние в отдельный провайдер. Это не универсально, но покрывает 90% кейсов.Race conditions в параллельных роутах
Parallel Routes выглядят мощно, но на практике легко получить состояние гонки. Когда два слота —
children и modal — одновременно делают fetch() с ревалидацией, один может тихо перезаписать кэш другого. Особенно больно, если у них loading.tsx с разным временем загрузки. Решение: используй generateMetadata для детерминированной загрузки и вешай ключи вроде key={searchParams.tab} на слоты, чтобы принудительно перемаунтить компонент. Костыль, но работает.Типизация Server Actions
Server Actions не наследуют типы при передаче в клиентские компоненты — TypeScript думает, что всё ок, а на деле тип съезжает, потенциально вызывая runtime-ошибку. Решение: вручную объявляй дженерик-тип:
type ActionState = { success: boolean; error?: string };
type ServerAction = (state: ActionState, formData: FormData) => Promise<ActionState>;
export const updateUser: ServerAction = async (state, formData) => {
'use server';
return { success: true };
};Типобезопасно, но требует явной аннотации — не доверяй автоматическому выводу.
Вывод: RSC в Next.js — мощный инструмент, но границы между клиентом и сервером, синхронизация кэша в параллельных роутах и явная типизация Server Actions требуют строгого инженерного контроля, иначе production ловит рассогласование данных.
Ambient type augmentation в declare module при динамической загрузке: скрытые конфликты с фактической типизацией и стратегии разрешения
В монорепозиториях
Проблема склейки в пересечение
Когда два пакета в монорепозитории объявляют
Динамический импорт и runtime-разрыв
При динамической загрузке через
Стратегии для монорепозиториев
* Изоляция через tsconfig paths — явно указывай пути к реальным файлам модуля и исключай лишние
* Wrapper-пакет с type guard — создай прослойку, которая делает динамический импорт и экспортирует явный тип. Контролируешь, что отдаёшь наружу, но добавляешь лишний слой.
* Маркер версии в declare module — добавь
Практический совет
Периодически прогоняй
Вывод: Ambient type augmentation в
В монорепозиториях
declare module в .d.ts файлах часто считают удобным способом типизировать динамически загружаемые модули. Однако на production это приводит к трудноуловимым багам, когда глобальные декларации из разных пакетов склеиваются, а не заменяются, создавая пересечения типов, которые не совпадают с реальным runtime-поведением.Проблема склейки в пересечение
Когда два пакета в монорепозитории объявляют
declare module 'lib' с разными версиями опций, TypeScript не выбирает одну — он объединяет их в пересечение. Результат: { mode: never }, если типы конфликтуют. Локально тесты проходят, а в production код падает из-за несовместимости. Типичная ошибка — считать, что declare module изолирован.Динамический импорт и runtime-разрыв
При динамической загрузке через
import('lib').then(...) TypeScript использует глобальные ambient-типы, а не фактическую сигнатуру модуля в рантайме. Если модуль загружается по условию (например, A/B-тест или фича-флаг), статически верный тип может не совпадать с реально загруженной версией. Результат — ошибки в рантайме, которые не ловятся статическим анализатором.Стратегии для монорепозиториев
* Изоляция через tsconfig paths — явно указывай пути к реальным файлам модуля и исключай лишние
.d.ts. Минус: накладные расходы на настройку каждого пакета.* Wrapper-пакет с type guard — создай прослойку, которая делает динамический импорт и экспортирует явный тип. Контролируешь, что отдаёшь наружу, но добавляешь лишний слой.
* Маркер версии в declare module — добавь
export const __version: '2.0.0' в декларацию. В коде видно, какая версия предполагалась, и можно проверить совпадение.Практический совет
Периодически прогоняй
tsc --noEmit --traceResolution в каждом пакете отдельно. Это покажет, откуда TypeScript берёт типы для declare module, и выявит случайные пересечения из node_modules или соседних пакетов. В монорепозиториях это обязательно, иначе баги с типами уйдут на production.Вывод: Ambient type augmentation в
declare module — опасный компромисс между удобством и надёжностью типов в монорепозиториях, требующий явной изоляции через tsconfig paths или wrapper-пакеты для предотвращения runtime-ошибок.Как я собираю мини‑аналитику по рынку профессий
Давно работая с HR‑аналитикой, стало интересно не просто смотреть на рынок, но и самому выделить что‑то основное, что можно собрать и представить: зарплатная аналитика, аналитика подбора персонала и тому подобное. Частные случаи отсутствия роста оплаты труда могут восприниматься людьми так, будто такое везде, но это может быть ошибкой. Год назад была достаточно сильная гонка зарплат, которая сейчас привела к акценту на производительности труда в стране. Многие ее не заметили.
Безработица низкая, что означает дефицит кадров. Но сейчас не дефицит кадров вообще, а дефицит квалифицированных кадров и дефицит рабочих. Без данных такие фразы превращаются в ощущения, а ощущения — плохая основа для выводов. Поэтому начат сбор небольшого аналитического проекта по рынку профессий. Идея простая: брать открытые данные, приводить их в порядок и собирать короткие профили по отдельным профессиям.
Читать далее
Давно работая с HR‑аналитикой, стало интересно не просто смотреть на рынок, но и самому выделить что‑то основное, что можно собрать и представить: зарплатная аналитика, аналитика подбора персонала и тому подобное. Частные случаи отсутствия роста оплаты труда могут восприниматься людьми так, будто такое везде, но это может быть ошибкой. Год назад была достаточно сильная гонка зарплат, которая сейчас привела к акценту на производительности труда в стране. Многие ее не заметили.
Безработица низкая, что означает дефицит кадров. Но сейчас не дефицит кадров вообще, а дефицит квалифицированных кадров и дефицит рабочих. Без данных такие фразы превращаются в ощущения, а ощущения — плохая основа для выводов. Поэтому начат сбор небольшого аналитического проекта по рынку профессий. Идея простая: брать открытые данные, приводить их в порядок и собирать короткие профили по отдельным профессиям.
Читать далее
Типизация FSM без библиотек: почему discriminated unions подводят в production и как это чинить
Discriminated unions в TypeScript отлично работают для статичных конечных автоматов, но в реальном production — с динамической сменой стейтов, асинхронными переходами и конфигами из плагинов — быстро проявляют лимиты. Частая ошибка: думать, что union решает все проблемы FSM, а потом ловить race conditions и невалидные переходы.
Закрытый union и динамические стейты
Union дискриминирован только на этапе компиляции. Если стейты подгружаются в рантайме — например, из конфига или через плагинную архитектуру — тип не расширить. Решение: карта стейтов через mapped types.
Новый стейт в
Нетипизированные переходы и гонки
DU типизирует только состояние, а не граф переходов. После loading можно прыгнуть в error, хотя бизнес-логика требует success. В production это даёт гонки при параллельных запросах. Попытка типизировать через guard-функции:
Но это не спасает от асинхронных race conditions — требуется флаг блокировки. Типы не гарантируют порядок, только композицию.
Контекст и дублирование полей
При 10+ стейтах общие поля (например, id заказа) дублируются в каждом типе. Вынос в дженерик — простое, но эффективное решение:
Так контекст не размазывается, а union остаётся читаемым.
Вывод: Discriminated unions — стартовая точка, но для production FSM без xstate обязательно нужны карта стейтов, типизированные переходы с guard'ами и вынесенный контекст, иначе на втором километре кода типы начнут врать при динамической смене состояний.
Discriminated unions в TypeScript отлично работают для статичных конечных автоматов, но в реальном production — с динамической сменой стейтов, асинхронными переходами и конфигами из плагинов — быстро проявляют лимиты. Частая ошибка: думать, что union решает все проблемы FSM, а потом ловить race conditions и невалидные переходы.
Закрытый union и динамические стейты
Union дискриминирован только на этапе компиляции. Если стейты подгружаются в рантайме — например, из конфига или через плагинную архитектуру — тип не расширить. Решение: карта стейтов через mapped types.
type StateMap = {
idle: Record<string, never>
loading: { progress: number }
error: { message: string }
}
type State = {
[K in keyof StateMap]: { status: K } & StateMap[K]
}[keyof StateMap]Новый стейт в
StateMap — и тип обновляется автоматически. Но типовая безопасность переходов остаётся хрупкой.Нетипизированные переходы и гонки
DU типизирует только состояние, а не граф переходов. После loading можно прыгнуть в error, хотя бизнес-логика требует success. В production это даёт гонки при параллельных запросах. Попытка типизировать через guard-функции:
function transition<S extends State, T extends State>(
prev: S, next: T, guard: (prev: S) => boolean
): T {
if (!guard(prev)) throw new Error('Invalid transition')
return next
}
Но это не спасает от асинхронных race conditions — требуется флаг блокировки. Типы не гарантируют порядок, только композицию.
Контекст и дублирование полей
При 10+ стейтах общие поля (например, id заказа) дублируются в каждом типе. Вынос в дженерик — простое, но эффективное решение:
type BaseState<T extends string, Ctx> = { status: T } & Ctx
type FSM =
| BaseState<'idle', {}>
| BaseState<'loading', { progress: number }>
| BaseState<'error', { message: string }>Так контекст не размазывается, а union остаётся читаемым.
Вывод: Discriminated unions — стартовая точка, но для production FSM без xstate обязательно нужны карта стейтов, типизированные переходы с guard'ами и вынесенный контекст, иначе на втором километре кода типы начнут врать при динамической смене состояний.
Conditional types в рантайме: как runtime type guards оживляют discriminated unions
Discriminated unions удобны в TypeScript, но в production их часто приходится проверять вручную через цепочки if и
Mapped type как контракт для runtime
Вместо того чтобы писать отдельный guard для каждого варианта union, определяем объект-гард через mapped type:
Теперь для каждой ветки (
Единая точка входа и generic фабрика
Добавляем общую функцию:
Типизация работает полностью: после
Можно уйти дальше — сделать generic-фабрику, которая возвращает строго типизированный guard для конкретного kind без кастингов внутри:
Типичная ошибка и trade-off
Ошибка: проверять только
Минус подхода: для глубоко вложенных дифференцированных union придется писать много ручной валидации. Лечится либо кодогенерацией, либо внешними библиотеками (zod, io-ts). Но для легаси с 10+ ветками такой подход выигрывает у хаоса из
Вывод: Соединение conditional типов с runtime guards превращает абстрактную типизацию в production-ready механизм проверки на границе доверия — особенно в API-клиентах, SDK и парсерах AST.
Discriminated unions удобны в TypeScript, но в production их часто приходится проверять вручную через цепочки if и
switch. Это ломает типы и множит ошибки на границе с внешними данными — API-ответами, JSON, AST-нодами. Решение: combine mapped types с runtime для автоматической генерации type guards.Mapped type как контракт для runtime
Вместо того чтобы писать отдельный guard для каждого варианта union, определяем объект-гард через mapped type:
type ShapeGuards = {
[K in Shape['kind']]: (obj: unknown) => obj is Extract<Shape, { kind: K }>
};Теперь для каждой ветки (
circle, rect, group) нужно только реализовать валидацию полей. Тип обяжет вернуть правильный предикат.Единая точка входа и generic фабрика
Добавляем общую функцию:
function isShape<T extends Shape['kind']>(kind: T, obj: unknown): obj is Extract<Shape, { kind: T }> {
return shapeGuards[kind](obj);
}Типизация работает полностью: после
isShape('circle', data) TypeScript знает, что data — это { kind: 'circle'; radius: number }. Для рекурсивных веток вроде group с вложенными Shape[] это тоже безопасно.Можно уйти дальше — сделать generic-фабрику, которая возвращает строго типизированный guard для конкретного kind без кастингов внутри:
type GuardOfKind<T> = T extends Shape['kind']
? (obj: unknown) => obj is Extract<Shape, { kind: T }>
: never;
function createKindGuard<T extends Shape['kind']>(kind: T): GuardOfKind<T> {
return shapeGuards[kind] as any;
}
Типичная ошибка и trade-off
Ошибка: проверять только
kind, а не поля варианта. TypeScript поведет, но в runtime придет некорректный объект — и сломается что-то ниже. Всегда проверяй специфичные поля: radius, shapes и т.д.Минус подхода: для глубоко вложенных дифференцированных union придется писать много ручной валидации. Лечится либо кодогенерацией, либо внешними библиотеками (zod, io-ts). Но для легаси с 10+ ветками такой подход выигрывает у хаоса из
switch и if-else.Вывод: Соединение conditional типов с runtime guards превращает абстрактную типизацию в production-ready механизм проверки на границе доверия — особенно в API-клиентах, SDK и парсерах AST.
Скрытые баги в
При работе с асинхронными операциями в Node.js или браузере часто используют
Проблема: гонка состояний и необработанные ошибки
Когда
Пример: если fetch упадёт с 500-м ответом на 3-й секунде, а таймаут срабатывает на 5-й, то второй reject — от таймаута — не будет пойман. Это классический race condition, который рвет цепочку error handling.
Отсутствие различимости таймаутов
Типобезопасная композиция: решение для цепочек
Для асинхронных операций, где нужно несколько таймаутов (например, сначала fetch, потом обработка данных),
Пример простой утилиты для оборачивания promise с таймаутом:
Здесь таймаут генерирует кастомную ошибку с понятным сообщением, которую можно отловить. Для цепочек — складывайте контроллеры в массив и управляйте ими вручную:
Типичная ошибка при композиции
Попытка объединить
Вывод: Не используйте
AbortSignal.timeout() и типобезопасная композиция AbortController в асинхронных цепочкахПри работе с асинхронными операциями в Node.js или браузере часто используют
AbortSignal.timeout() для ограничения времени выполнения. Это удобно, но таит подводные камни, особенно в цепочках promise или при композиции нескольких timeout-сигналов. Разработчики нередко забывают про race condition между завершением запроса и отменой, а также про невозможность отличить таймаут от ручного прерывания без явной настройки причины.Проблема: гонка состояний и необработанные ошибки
Когда
AbortSignal.timeout(5000) передаётся в fetch, а запрос завершается раньше таймаута, сигнал просто игнорируется — это нормально. Но если запрос падает с ошибкой до срабатывания таймаута, сам таймаут всё равно вызывает abort. Возникает ситуация: ошибка уже обработана в catch, а AbortError от таймаута остаётся необработанным. В консоли появляется unhandled rejection.Пример: если fetch упадёт с 500-м ответом на 3-й секунде, а таймаут срабатывает на 5-й, то второй reject — от таймаута — не будет пойман. Это классический race condition, который рвет цепочку error handling.
Отсутствие различимости таймаутов
AbortSignal.timeout() бросает DOMException с именем AbortError. Свой AbortController при вызове .abort() тоже создаёт такой же exception. В итоге в catch нельзя отличить, что пошло не так: таймаут или ручная отмена. Единственный способ — задать signal.reason при abort, но по умолчанию он undefined. Это делает диагностику сложной, особенно в production.try {
await fetch(url, { signal });
} catch (err) {
if (err.name === 'AbortError') {
// Что это? Таймаут? Ручной abort? Непонятно.
}
}Типобезопасная композиция: решение для цепочек
Для асинхронных операций, где нужно несколько таймаутов (например, сначала fetch, потом обработка данных),
AbortSignal.any() — не выход: он не сохраняет причину и не убирает race condition. Вместо этого используйте композицию контроллеров с явным контролем таймаутов.Пример простой утилиты для оборачивания promise с таймаутом:
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(new Error('Timeout')), ms);
return Promise.race([
promise,
new Promise<never>((_, reject) => {
controller.signal.addEventListener('abort', () => reject(controller.signal.reason));
})
]).finally(() => clearTimeout(id));
}Здесь таймаут генерирует кастомную ошибку с понятным сообщением, которую можно отловить. Для цепочек — складывайте контроллеры в массив и управляйте ими вручную:
const acc = new AbortController();
const fetchPromise = fetch(url, { signal: acc.signal });
const timeoutId = setTimeout(() => acc.abort(new Error('Fetch timeout')), 5000);
fetchPromise.finally(() => clearTimeout(timeoutId));
Типичная ошибка при композиции
Попытка объединить
AbortSignal.timeout() с собственным контроллером через AbortSignal.any() приводит к потере контроля над причиной и времени отмены. Если таймаут от AbortSignal.timeout() сработает первым, ваш контроллер никогда не будет вызван, что приводит к утечке ресурсов (например, незавершённый HTTP-запрос).Вывод: Не используйте
AbortSignal.timeout() в production без явной обработки причины и контроля race condition — для асинхронных цепочек надёжнее собрать композицию вручную с кастомными ошибками и явным управлением таймаутами.Feature flags на TypeScript: типобезопасная композиция с branded types и runtime validation для production-конфигураций
Feature flags в production — удобно, но конфиги часто превращаются в мешанину строк. Простое
Branded types для compile-time безопасности
Тип
Runtime validation с Zod
Даже с branded types в JSON может прилететь невалидное значение. Добавляем схему с
Дискриминируемые union для состояний флагов
Когда два флага конфликтуют (например,
* Предупреждение: branded types добавляют шум в код и усложняют вложенные конфиги без линтера. Для плоских A/B тестов и канареечных релизов это окупается — ошибка ловится на этапе компиляции, а не в production.
* Совет: комбинируй discriminated union с zod-схемой на весь конфиг — тогда даже опечатка в JSON упадёт с понятным сообщением, а не молча сломает логику.
Вывод: Типобезопасная композиция флагов через branded types и runtime-валидация даёт двухуровневую защиту — compile-time строгость и runtime-проверку входных данных, что критично для надёжности A/B экспериментов и канареечных деплоев.
Feature flags в production — удобно, но конфиги часто превращаются в мешанину строк. Простое
{ isNewFeature: true } не защищает от опечаток вроде isNewFeautre, и линтер молчит до прогона тестов или деплоя. Используем branded types с Zod для строгой проверки на этапе компиляции и выполнения.Branded types для compile-time безопасности
Тип
Brand<T, B> добавляет уникальную метку. Передать простой boolean вместо NewFeature не выйдет — TS потребует явного приведения. Это исключает путаницу между флагами:type Brand<T, B> = T & { __brand: B };
type NewFeature = Brand<boolean, 'NewFeature'>;
type LegacyFallback = Brand<boolean, 'LegacyFallback'>;
function useFeature(newFeature: NewFeature, legacyFallback: LegacyFallback): boolean {
return newFeature || legacyFallback;
}Runtime validation с Zod
Даже с branded types в JSON может прилететь невалидное значение. Добавляем схему с
z.boolean().brand() — тогда при парсинге ошибка будет явной, а не тихим undefined:const ConfigSchema = z.object({
newFeature: z.boolean().brand<'NewFeature'>(),
legacyFallback: z.boolean().brand<'LegacyFallback'>(),
});
type Config = z.infer<typeof ConfigSchema>;
function loadConfig(raw: unknown): Config {
return ConfigSchema.parse(raw);
}Дискриминируемые union для состояний флагов
Когда два флага конфликтуют (например,
newFeature и legacyFallback), обычные типы не мешают включить оба одновременно. Discriminated union сужает область до корректных комбинаций — это предотвращает два источника истины:type FlagState =
| { newFeature: true; legacyFallback: false }
| { newFeature: false; legacyFallback: boolean };
function processState(state: FlagState) {
if (state.newFeature) {
return state.legacyFallback; // TS сужает до false
}
return state.legacyFallback;
}
* Предупреждение: branded types добавляют шум в код и усложняют вложенные конфиги без линтера. Для плоских A/B тестов и канареечных релизов это окупается — ошибка ловится на этапе компиляции, а не в production.
* Совет: комбинируй discriminated union с zod-схемой на весь конфиг — тогда даже опечатка в JSON упадёт с понятным сообщением, а не молча сломает логику.
Вывод: Типобезопасная композиция флагов через branded types и runtime-валидация даёт двухуровневую защиту — compile-time строгость и runtime-проверку входных данных, что критично для надёжности A/B экспериментов и канареечных деплоев.
Symbol.toStringTag — тот самый символ, который ломает всё подряд
Казалось бы, просто добавил
Сериализация и JSON
Прямого влияния на
* Типичная ошибка: проверять тип через тег, а не через
* Практический совет: в кастомной сериализации используй отдельное поле
Прототипы под прицелом
Многие фреймворки определяют тип через
* Предупреждение: не трогай
* Trade-off: изменяя тег на прототипе встроенных типов, ты создаёшь скрытые зависимости от строковых сравнений в чужих модулях.
shouldOverrideVisibility и production-фреймворки
В недрах React Native или Vue 2 composition API есть функция, которая проверяет видимость компонента через
Пример бага:
* Чтобы защититься в HOC: прокидывай тег через
Вывод: Маленький символ
Казалось бы, просто добавил
[Symbol.toStringTag] в класс, получил [object MyCoolClass] — и радуешься. Но production показывает, что это "удобство" превращает дебаг в квест: особенно когда фреймворк или библиотека ожидают строгий "Object", "Array" или "Promise", а получают что-то своё.Сериализация и JSON
Прямого влияния на
JSON.stringify() нет. Но если пишешь свой toJSON(), где проверяешь this[Symbol.toStringTag] — вот тут баг. Класс-наследник с другим тегом превращается в родительский тип. Я такое ловил, когда сериализовал DTO: потомок считался предком, и бэкенд падал с валидацией.* Типичная ошибка: проверять тип через тег, а не через
this.constructor.name.* Практический совет: в кастомной сериализации используй отдельное поле
_typeTag.Прототипы под прицелом
Многие фреймворки определяют тип через
Object.prototype.toString.call(obj). Зачем? Потому что typeof не отличает Array от обычного объекта. А если ты переопределил тег в прототипе (например, сделал Array.prototype[Symbol.toStringTag] = "MyArray") — библиотека, ожидающая "Array", просто сломает иммутабельность или реактивность. Видел такое в старых полифиллах.* Предупреждение: не трогай
Symbol.toStringTag в прототипах Array, Promise, Map — движок использует их для внутренних проверок.* Trade-off: изменяя тег на прототипе встроенных типов, ты создаёшь скрытые зависимости от строковых сравнений в чужих модулях.
shouldOverrideVisibility и production-фреймворки
В недрах React Native или Vue 2 composition API есть функция, которая проверяет видимость компонента через
Symbol.toStringTag. Кейс: обернул компонент в HOC, HOC перетирает тег. shouldOverrideVisibility сравнивает тег с ожидаемым — и игнорирует компонент как "чужой". Элементы рендерятся, но никогда не становятся видимыми. Это не баг документации — это примитивная проверка на уровне типа.Пример бага:
class Base {
get [Symbol.toStringTag]() { return "Base" }
}
class Child extends Base {}
// Child будет сериализоваться как Base* Чтобы защититься в HOC: прокидывай тег через
Object.defineProperty с оригинальным значением.Вывод: Маленький символ
Symbol.toStringTag — это скрытая точка отказа при сериализации, защите прототипов и в production-фреймворках, поэтому относись к нему как к мине — лучше не трогать, если не контролируешь все уровни стека.Buffer в браузере: портирование Node.js-кода на Web Streams API и неочевидные грабли с TextEncoder/TextDecoder в production
Перетащить обработку бинарных данных из Node.js в браузер — берешь Buffer, оборачиваешь в Uint8Array и production падает. Проблема в том, что Buffer — глобал из Node.js, его нет в браузере, а полифилы тащат лишние килобайты. Современный путь — Web Streams API и TextEncoder/TextDecoder, но есть грабли, которые ломают production.
Грабли 1. slice не уважает UTF-8
В Node.js
Грабли 2. Потоковое декодирование без stream: true
Web Streams отдают данные чанками. Если декодировать каждый чанк как отдельную строку, последний может быть урезан — многобайтовый символ разобьется на два чанка. Типичная ошибка:
Нужно так:
Пропустишь
Грабли 3. Утечка памяти из-за создания инстансов
Создание
Практические советы при портировании
-
-
- Для энкодинга/декодинга используй только
- Никогда не доверяй прямому
Вывод: Грабли не в том, что Buffer нет в браузере, а в том, что TextEncoder.encode ведет себя иначе — учитывай многобайтовые символы, кэшируй инстансы и всегда декодируй с stream: true.
Перетащить обработку бинарных данных из Node.js в браузер — берешь Buffer, оборачиваешь в Uint8Array и production падает. Проблема в том, что Buffer — глобал из Node.js, его нет в браузере, а полифилы тащат лишние килобайты. Современный путь — Web Streams API и TextEncoder/TextDecoder, но есть грабли, которые ломают production.
Грабли 1. slice не уважает UTF-8
В Node.js
Buffer.from('Привет!').slice(0, 5) дает 5 байт и строку 'Прив'. В браузере new TextEncoder().encode('Привет!').slice(0, 5) режет посреди многобайтового символа — получаешь битый байт. В production это вылезает при обрезании логов с кириллицей или эмодзи. Решение: не используй slice на Uint8Array вслепую, применяй subarray и TextDecoder с stream: true для восстановления границ.Грабли 2. Потоковое декодирование без stream: true
Web Streams отдают данные чанками. Если декодировать каждый чанк как отдельную строку, последний может быть урезан — многобайтовый символ разобьется на два чанка. Типичная ошибка:
// Ломается на границе чанков
for await (const chunk of reader) {
const str = new TextDecoder().decode(chunk);
}
Нужно так:
const decoder = new TextDecoder('utf-8', { stream: true });
for await (const chunk of reader) {
const str = decoder.decode(chunk, { stream: true });
}
const final = decoder.decode(); // завершающий вызовПропустишь
stream: true — получишь битые данные на границах чанков, например, при парсинге CSV с кириллицей в production.Грабли 3. Утечка памяти из-за создания инстансов
Создание
new TextEncoder() внутри цикла или хендлера запросов вызывает GC-шторм. Вынеси в константы модуля один раз. Тоже самое с TextDecoder — кэшируй для всех чанков потока.Практические советы при портировании
-
Buffer.concat заменяй на ручное объединение через new Uint8Array(totalLength) и set.-
fs.createReadStream заменяй на fetch + response.body (ReadableStream).- Для энкодинга/декодинга используй только
TextEncoder/TextDecoder с stream: true на чанках.- Никогда не доверяй прямому
slice по Uint8Array, если внутри UTF-8 — это ломает строки с акцентами, кириллицей и эмодзи в production.Вывод: Грабли не в том, что Buffer нет в браузере, а в том, что TextEncoder.encode ведет себя иначе — учитывай многобайтовые символы, кэшируй инстансы и всегда декодируй с stream: true.
WeakRef и FinalizationRegistry: слабые ссылки как production-ready инструмент управления памятью
В асинхронных пайплайнах — от Node.js-сервисов до SSR и heavy client-side кэширования — каждый временный объект (кэши, буферы, промисы, слушатели) держится сильной ссылкой, блокируя сборщик мусора. Типичная ошибка: держать в Map результаты дорогих API-вызовов, не понимая, что Map хранит сильные ссылки, и память растёт бесконтрольно.
WeakRef: слабая ссылка для кэширования без утечек
WeakRef позволяет GC очищать объект, если на него нет сильных ссылок. Идеально для кэша, где данные можно пересоздать — экономит память под нагрузкой:
GC решает, когда удалить объект. Если памяти хватает — данные живут. Если нет — кэш "сдувается" без ручной очистки и утечек.
FinalizationRegistry: детекция утечек и чистка ресурсов
Колбэк при удалении объекта — можно отслеживать, что объект не был освобождён вовремя:
Практический совет: используй в отладке для обнаружения утечек долгоживущих объектов — подписок, web-сокетов, буферов.
Типобезопасная обёртка с generic
TypeScript дружит с WeakRef через generic, что даёт надёжные типы:
Предупреждение: WeakRef не работают с примитивами (string, number) — только объекты. Для примитивов используй
Вывод: Слабые ссылки — не экзотика, а инженерный инструмент для надёжного управления памятью, где главное правило — "используй, если данные можно пересоздать, и никогда не полагайся на детерминизм GC".
В асинхронных пайплайнах — от Node.js-сервисов до SSR и heavy client-side кэширования — каждый временный объект (кэши, буферы, промисы, слушатели) держится сильной ссылкой, блокируя сборщик мусора. Типичная ошибка: держать в Map результаты дорогих API-вызовов, не понимая, что Map хранит сильные ссылки, и память растёт бесконтрольно.
WeakRef: слабая ссылка для кэширования без утечек
WeakRef позволяет GC очищать объект, если на него нет сильных ссылок. Идеально для кэша, где данные можно пересоздать — экономит память под нагрузкой:
const cache = new Map<string, WeakRef<any>>();
const registry = new FinalizationRegistry((key: string) => {
cache.delete(key);
});
async function fetchData(key: string): Promise<any> {
let ref = cache.get(key);
let cached = ref?.deref();
if (cached) return cached;
const data = await expensiveAPICall(key);
const weakRef = new WeakRef(data);
cache.set(key, weakRef);
registry.register(data, key);
return data;
}
GC решает, когда удалить объект. Если памяти хватает — данные живут. Если нет — кэш "сдувается" без ручной очистки и утечек.
FinalizationRegistry: детекция утечек и чистка ресурсов
Колбэк при удалении объекта — можно отслеживать, что объект не был освобождён вовремя:
class LeakDetector {
private registry: FinalizationRegistry<string>;
private active = new Map<string, number>();
constructor(onLeak: (name: string) => void) {
this.registry = new FinalizationRegistry((name: string) => {
const count = (this.active.get(name) ?? 0) - 1;
if (count <= 0) this.active.delete(name);
else this.active.set(name, count);
onLeak(name);
});
}
track(obj: object, name: string) {
this.active.set(name, (this.active.get(name) ?? 0) + 1);
this.registry.register(obj, name);
}
}Практический совет: используй в отладке для обнаружения утечек долгоживущих объектов — подписок, web-сокетов, буферов.
Типобезопасная обёртка с generic
TypeScript дружит с WeakRef через generic, что даёт надёжные типы:
class WeakCache<T extends object> {
private refs = new Map<string, WeakRef<T>>();
private registry = new FinalizationRegistry<string>((key) => {
this.refs.delete(key);
});
set(key: string, value: T): void {
const ref = new WeakRef(value);
this.refs.set(key, ref);
this.registry.register(value, key);
}
get(key: string): T | undefined {
return this.refs.get(key)?.deref();
}
}Предупреждение: WeakRef не работают с примитивами (string, number) — только объекты. Для примитивов используй
WeakMap или обёртки. Также не используй WeakRef для критичных по времени кэшей — GC непредсказуем.Вывод: Слабые ссылки — не экзотика, а инженерный инструмент для надёжного управления памятью, где главное правило — "используй, если данные можно пересоздать, и никогда не полагайся на детерминизм GC".
Lazy evaluation с Proxy и мемоизацией: скрытые costs при дебаге, сериализации и неожиданные mutation-баги в production
Ленивое вычисление через Proxy обещает изящное отложенное исполнение, но в production эта абстракция превращается в источник трудноуловимых багов. Часто разработчики не учитывают побочные эффекты для дебага, сериализации и мутаций, что приводит к критическим инцидентам в API-клиентах, SSR или shared libraries.
1. Дебаг превращается в квест
Когда Proxy вычисляет значение лениво, в stack trace видно только финальный результат. Chrome DevTools сам вызывает геттер при инспекции, что может спровоцировать нежелательные вычисления второй раз. Пример:
Совет: используй дебаг через отладчик с явными брейкпоинтами, а не через console.log, который может перезапустить мемоизацию.
2. Сериализация умирает молча
Иначе — потеря данных без ошибки.
3. Неожиданные mutation-баги с reference-запахом
Когда ты кэшируешь вычисленный объект, а затем кто-то мутирует его, следующий доступ вернет измененное значение. Пример:
Вывод: Proxy с мемоизацией — мощный, но хрупкий паттерн; всегда тестируй сериализацию и мутации, а в критических путях лучше используй явные геттеры.
Ленивое вычисление через Proxy обещает изящное отложенное исполнение, но в production эта абстракция превращается в источник трудноуловимых багов. Часто разработчики не учитывают побочные эффекты для дебага, сериализации и мутаций, что приводит к критическим инцидентам в API-клиентах, SSR или shared libraries.
1. Дебаг превращается в квест
Когда Proxy вычисляет значение лениво, в stack trace видно только финальный результат. Chrome DevTools сам вызывает геттер при инспекции, что может спровоцировать нежелательные вычисления второй раз. Пример:
const proxy = new Proxy({}, {
get(target, key) {
if (!(key in target)) { target[key] = compute(key); }
return target[key];
}
});
// console.log в getter может вызвать повторный computeСовет: используй дебаг через отладчик с явными брейкпоинтами, а не через console.log, который может перезапустить мемоизацию.
2. Сериализация умирает молча
JSON.stringify(proxy) вернет {}, если не перехватывать ownKeys и getOwnPropertyDescriptor. Пропусти это — и данные исчезнут при передаче в REST или localStorage. Типичная ошибка: думать, что Proxy автоматически виден для сериализации. Практический совет: явно реализуй обработчики:const proxy = new Proxy(target, {
get(target, key) { /*... лениво вычисляем */ },
ownKeys() { return [...Object.keys(target), ...dynamicKeys]; },
getOwnPropertyDescriptor(_, key) { return { configurable: true, enumerable: true }; }
});Иначе — потеря данных без ошибки.
3. Неожиданные mutation-баги с reference-запахом
Когда ты кэшируешь вычисленный объект, а затем кто-то мутирует его, следующий доступ вернет измененное значение. Пример:
const data = proxy.items; data.push('new'); — следующий вызов proxy.items вернет ту же ссылку, что ломает идемпотентность. Это особенно опасно в state-менеджменте или при параллельных запросах. Предупреждение: никогда не возвращай мутабельные ссылки из Proxy без глубокого копирования, иначе получишь гонки данных, которые сложно воспроизвести локально.Вывод: Proxy с мемоизацией — мощный, но хрупкий паттерн; всегда тестируй сериализацию и мутации, а в критических путях лучше используй явные геттеры.
Изящный баг в Edge Runtime: prototype pollution через structuredClone и Symbol-свойства
Вы доверяете structuredClone защиту от прототип-поллюшена? Зря. В Edge Runtime старых версий (V8) баг позволяет Symbol-ключам с именем
Как это работает
Обычная защита от prototype pollution строится на проверке ключей через
В уязвимых версиях Edge Runtime этот символ неправильно обрабатывается как строковой ключ, и объект
Почему это изящно
-
-
- Но structuredClone обрабатывает Symbol-ключи (кроме well-known). Механизм безопасного клонирования становится вектором атаки через незаметные Symbol.
Типичная ошибка
Думать, что structuredClone изолирует от prototype pollution, если вы проверяете только строковые ключи. В code review я встречал код, где после глубокого клонирования входных данных объект проверялся
Production-совет
Для временных данных из ненадёжных источников используйте
Вывод: structuredClone не является панацеей от prototype pollution — Symbol-ключи с именами
Вы доверяете structuredClone защиту от прототип-поллюшена? Зря. В Edge Runtime старых версий (V8) баг позволяет Symbol-ключам с именем
__proto__ при клонировании записаться как строковые ключи, обходя стандартные проверки. Это особенно опасно в API-серверах на Node.js и edge-функциях, где входящие данные не проходят валидацию.Как это работает
Обычная защита от prototype pollution строится на проверке ключей через
Object.keys() или for...in. Но эти методы игнорируют Symbol-ключи. Злоумышленник вставляет символ с именем __proto__:const payload = {
[Symbol('__proto__')]: { polluted: true }
};
const clean = structuredClone(payload);В уязвимых версиях Edge Runtime этот символ неправильно обрабатывается как строковой ключ, и объект
clean получает прототип-загрязнение.Почему это изящно
-
Object.keys() и for...in не видят Symbol-ключи — стандартная проверка их пропускает.-
JSON.parse(JSON.stringify()) теряет Symbol — многие разработчики перешли на structuredClone, считая его безопаснее.- Но structuredClone обрабатывает Symbol-ключи (кроме well-known). Механизм безопасного клонирования становится вектором атаки через незаметные Symbol.
Типичная ошибка
Думать, что structuredClone изолирует от prototype pollution, если вы проверяете только строковые ключи. В code review я встречал код, где после глубокого клонирования входных данных объект проверялся
Object.keys() — Symbol остались незамеченными.Production-совет
Для временных данных из ненадёжных источников используйте
Object.create(null) — он блокирует прототипную цепочку. Дополнительно, проверяйте Symbol-ключи вручную: Object.getOwnPropertySymbols(obj). Если вы используете edge runtime — обновляйте его до актуальной версии, где этот баг исправлен.Вывод: structuredClone не является панацеей от prototype pollution — Symbol-ключи с именами
__proto__ могут обходить защиту, если вы не обрабатываете их явно.Branded Types в TypeScript: как заставить типы работать в рантайме
Типы в TypeScript исчезают при компиляции, но в production мы сталкиваемся с данными из внешнего мира, где структурная типизация не способна защитить от путаницы между двумя сущностями одного базового типа — например, Email и UserId, объявленными как строки. Branded Types с Zod-схемами позволяют сохранить семантику в compile-time и runtime-валидацию для production-кода.
Проблема структурной типизации
Структурная типизация TypeScript не отличает Email от UserId, если оба — строки. Это приводит к ошибкам, когда в функцию отправки письма передаётся идентификатор пользователя. Branded Types добавляют невидимую метку на уровне типов, но не обеспечивают проверку в рантайме. Zod даёт реальную валидацию при работе с API, формами или env.
Решение с Zod и branded types
Используйте Zod для парсинга данных, извлекая типы через z.infer и добавляя brand-метку. Пример:
Типичная ошибка — попытка передать сырую строку напрямую в sendEmail. Branded type отсечёт это на этапе компиляции, а схема Zod перехватит некорректные данные из API ещё до логики.
Где применять и trade-offs
Подходит для моделей UserId, OrderId, SKU, чтобы избежать путаницы в API-клиентах, SDK или легаси-миграциях. Но:
- Не защищает от злонамеренной подделки — это не криптография, а дисциплина типов.
- Легко переборщить: для простых DTO достаточно обычных структурных типов.
- Без командных договорённостей бренды становятся кашей — кто-то использует Zod, кто-то самописные guards. Практический совет: ограничьте бренды только критичными сущностями на границе модулей.
Вывод: Branded Types с Zod решают проблему runtime-гарантий для доменных моделей за счёт дисциплины типов и zero-cost метки, но требуют строгих конвенций в команде.
Типы в TypeScript исчезают при компиляции, но в production мы сталкиваемся с данными из внешнего мира, где структурная типизация не способна защитить от путаницы между двумя сущностями одного базового типа — например, Email и UserId, объявленными как строки. Branded Types с Zod-схемами позволяют сохранить семантику в compile-time и runtime-валидацию для production-кода.
Проблема структурной типизации
Структурная типизация TypeScript не отличает Email от UserId, если оба — строки. Это приводит к ошибкам, когда в функцию отправки письма передаётся идентификатор пользователя. Branded Types добавляют невидимую метку на уровне типов, но не обеспечивают проверку в рантайме. Zod даёт реальную валидацию при работе с API, формами или env.
Решение с Zod и branded types
Используйте Zod для парсинга данных, извлекая типы через z.infer и добавляя brand-метку. Пример:
import { z } from 'zod';
const EmailSchema = z.string().email().brand('Email');
type Email = z.infer<typeof EmailSchema>;
function sendEmail(to: Email) { /* */ }
const email = EmailSchema.parse('user@example.com');
sendEmail(email); // compile-time + runtime pass
Типичная ошибка — попытка передать сырую строку напрямую в sendEmail. Branded type отсечёт это на этапе компиляции, а схема Zod перехватит некорректные данные из API ещё до логики.
Где применять и trade-offs
Подходит для моделей UserId, OrderId, SKU, чтобы избежать путаницы в API-клиентах, SDK или легаси-миграциях. Но:
- Не защищает от злонамеренной подделки — это не криптография, а дисциплина типов.
- Легко переборщить: для простых DTO достаточно обычных структурных типов.
- Без командных договорённостей бренды становятся кашей — кто-то использует Zod, кто-то самописные guards. Практический совет: ограничьте бренды только критичными сущностями на границе модулей.
Вывод: Branded Types с Zod решают проблему runtime-гарантий для доменных моделей за счёт дисциплины типов и zero-cost метки, но требуют строгих конвенций в команде.
❤1
Как я хакнул рынок труда: пишем свой ИИ-комбайн для автооткликов на HH.ru
Всем привет! Если вы хоть раз искали работу в IT за последний год, то знаете, что рынок беспощаден к новичкам. Нужно откликнуться на сотни вакансий, а в итоге получаешь отказы от роботов. Чтобы пробиться через фильтры HR, нужно под каждую вакансию писать уникальное сопроводительное письмо.
В этой статье я сделаю полный разбор того, как я написал собственного автономного ИИ-агента, который ищет вакансии, фильтрует мусор с помощью локальной нейросети, пишет персонализированные сопроводительные письма и отчитывается мне в Telegram, пока я спокойно занимаюсь своими делами. Я хотел, чтобы скрипт был бесплатным, автономным и не требовал танцев с бубном вокруг платных API.
Читать далее на Habr
Всем привет! Если вы хоть раз искали работу в IT за последний год, то знаете, что рынок беспощаден к новичкам. Нужно откликнуться на сотни вакансий, а в итоге получаешь отказы от роботов. Чтобы пробиться через фильтры HR, нужно под каждую вакансию писать уникальное сопроводительное письмо.
В этой статье я сделаю полный разбор того, как я написал собственного автономного ИИ-агента, который ищет вакансии, фильтрует мусор с помощью локальной нейросети, пишет персонализированные сопроводительные письма и отчитывается мне в Telegram, пока я спокойно занимаюсь своими делами. Я хотел, чтобы скрипт был бесплатным, автономным и не требовал танцев с бубном вокруг платных API.
Читать далее на Habr
Temporal API: неочевидные edge cases с часовыми поясами, границами дней и производительность в production
Temporal — долгожданная замена Date и moment.js, но она не делает магию, а лишь делает её предсказуемой. В production три сценария могут сломать логику: DST-разрывы, ложные сравнения и скрытые аллокации.
1. Граница дня — это не 24 часа: DST создаёт несуществующие моменты
Перевод часов в часовом поясе с летним временем — классическая ловушка. Попытка создать ZonedDateTime с несуществующим временем, например 02:30 в
Решение — опция
2. Сравнение ZonedDateTime: equals() игнорирует часовой пояс
Это ошибка: в
3. Производительность: Temporal не бесплатен — профилируйте
Каждый
На 100k+ событиях в секунду с разными таймзонами замена Date на Temporal дала рост CPU на 40% на Node.js 20. Для редких вычислений Temporal отличен, но для hot path используйте
Вывод: Temporal решает 90% старых проблем, но DST-разрывы, сравнение по календарю и неучтённые аллокации — ваши новые враги; профилируйте и тестируйте на границах дней с реальными данными.
Temporal — долгожданная замена Date и moment.js, но она не делает магию, а лишь делает её предсказуемой. В production три сценария могут сломать логику: DST-разрывы, ложные сравнения и скрытые аллокации.
1. Граница дня — это не 24 часа: DST создаёт несуществующие моменты
Перевод часов в часовом поясе с летним временем — классическая ловушка. Попытка создать ZonedDateTime с несуществующим временем, например 02:30 в
America/New_York при переходе на DST:const zdt = Temporal.ZonedDateTime.from({
year: 2024, month: 3, day: 10,
timeZone: 'America/New_York',
hour: 2, minute: 30
}); // RangeError: момент не существуетРешение — опция
{ disambiguation: 'earlier' } или 'later'. Но 'later' даст 03:30 вместо ожидаемых 02:30. Логика с startOfDay() обязана это учитывать. Иначе баг воспроизводится раз в полгода.2. Сравнение ZonedDateTime: equals() игнорирует часовой пояс
equals() сравнивает календарные поля, а не абсолютное время:const a = Temporal.ZonedDateTime.from('2024-01-01[UTC]');
const b = Temporal.ZonedDateTime.from('2024-01-01[Asia/Tokyo]');
a.equals(b); // true — часы и даты совпадают, но момент разный
a.epochMilliseconds === b.epochMilliseconds; // false — физически разные моментыЭто ошибка: в
[UTC] и [Asia/Tokyo] один и тот же календарный день — это разные моменты, отстоящие на +9 часов. Для точного сравнения всегда приводите к Instant или используйте .since(). Иначе половина пользователей получит неверные расписания.3. Производительность: Temporal не бесплатен — профилируйте
Каждый
ZonedDateTime.from() с нестандартным часовым поясом парсит базу IANA TZif — это поиск по файлу. .until() / .since() не ленивы: сразу вычисляют разницу по всем полям. А toPlainDate() не кешируется — каждый вызов аллоцирует новый объект.На 100k+ событиях в секунду с разными таймзонами замена Date на Temporal дала рост CPU на 40% на Node.js 20. Для редких вычислений Temporal отличен, но для hot path используйте
Intl.DateTimeFormat + Date.getTime().Вывод: Temporal решает 90% старых проблем, но DST-разрывы, сравнение по календарю и неучтённые аллокации — ваши новые враги; профилируйте и тестируйте на границах дней с реальными данными.
❤1