Инкапсуляция прячет состояние объекта за его методами, абстракция показывает, что объект делает, а не как, наследование позволяет подклассу переиспользовать код класса, а полиморфизм позволяет одному вызову работать с любым объектом подходящего типа. В TypeScript все четыре работают поверх прототипов JavaScript.
Инкапсуляция: #balanceCents меняется только через topUp() и pay(), поэтому никто снаружи не сделает баланс отрицательным; замыкание даёт ту же приватность без класса.
Абстракция: код оформления заказа видит только pay(amount); токенизация и 3-D Secure остаются внутри реализации.
Наследование: PaymentError extends Error сохраняет message и стек вызовов и добавляет status; методы ищутся по цепочке прототипов, а не копируются.
Полиморфизм: method.pay(700) выполняет код кошелька или карты. TypeScript проверяет это структурно: подходит любой объект с подходящим методом pay.
ReactMiddle
ООП, SOLID и паттерны проектирования
Классы и ООП в TypeScript, SOLID, DRY, KISS и YAGNI, связность и зацепление, паттерны GoF во фронтенде: observer, module, factory, strategy, adapter, facade, proxy, антипаттерны
Закрыто вопросов
0 / 20
- 0Повторить
- 7Часто спрашивают
Вопросы
private остаётся проверкой TypeScript, которая исчезает после компиляции, поэтому в рантайме поле становится обычным свойством. #private объявляет приватное поле JavaScript, и его защищает сам движок. protected дополнительно открывает член класса для подклассов, а readonly запрещает присваивать его после конструктора.
**private мягкий**: acc['pin'] компилируется (намеренная лазейка), а JSON.stringify и консоль показывают поле.
**#private жёсткий**: недоступен вне тела класса даже через any и не попадает в JSON; #token in value выполняет проверку бренда (brand check), которая даёт true только для настоящих экземпляров.
**Цена #**: подклассы не могут его читать, а методы, которые читают #-поля, падают при вызове через Proxy, например через reactive() во Vue.
**protected и readonly** работают только при компиляции. readonly items: string[] запрещает переприсвоить свойство, но не items.push(); для этого нужен readonly string[].
Интерфейс задаёт только контракт: форму без кода, которая исчезает после компиляции. Абстрактный класс остаётся настоящим классом, экземпляр которого создать нельзя: он может хранить поля и реализованные методы для подклассов. По умолчанию берите интерфейс, а абстрактный класс, когда у подклассов есть настоящий общий код.
Интерфейс: класс может реализовать несколько интерфейсов, и подходит любой объект нужной формы, поэтому в тесте фейком служит обычный объектный литерал.
Абстрактный класс: подклассы обязаны реализовать его abstract-члены и наследуют остальное, включая конструктор и protected-хелперы; instanceof работает, потому что класс существует в рантайме.
Шаблонный метод (template method), типичный удачный случай: ChunkedUploader.upload() фиксирует алгоритм, а подклассы дописывают start() и sendChunk().
Только один базовый класс: абстрактная база запирает иерархию, поэтому, когда общего кода немного, интерфейс плюс функция-хелпер оставляют больше свободы.
DRY (don't repeat yourself) требует, чтобы каждое знание жило в одном месте, KISS (keep it simple) велит выбирать самое простое работающее решение, а YAGNI (you aren't gonna need it) запрещает строить под воображаемые потребности. Слияние дублей вредит, когда копии лишь похожи внешне.
DRY касается знания, а не текста: правило для пароля, которое проверяют две формы и API, со временем разойдётся; одна схема Zod, общая для формы и сервера, держит его в одном месте. Похожий JSX не является общим знанием.
Неверная абстракция: две карточки, слитые в один <Card> с isCompact, showPrice и variant="legacy", связывают экраны, которые меняются по разным причинам; дублирование намного дешевле неверной абстракции (Сэнди Метц).
Правило трёх: выносите общий код на третьей копии, когда уже видно, что на самом деле меняется.
KISS и YAGNI: функция раньше иерархии классов, useState раньше библиотеки состояния; никакого универсального DataTable «для будущих таблиц» и никакой системы плагинов ради одного плагина, потому что код под воображаемую потребность редко подходит под реальную.
Связанность (coupling) показывает, насколько модуль зависит от деталей других модулей; связность (cohesion) показывает, насколько код внутри одного модуля относится к одному делу. Нужны слабая связанность и сильная связность: тогда изменение остаётся в одном месте, а у каждого модуля одна понятная задача.
Стрельба дробью (shotgun surgery): переименование одного поля API затрагивает 30 компонентов, потому что каждый читает сырой ответ. Преобразуйте ответы в свои типы в слое API, и изменение остановится там.
Связанность с конкретными вещами: компонент, который напрямую импортирует axios, localStorage или модуль стора, нельзя переиспользовать или протестировать без них.
Скрытая связанность: общий глобальный объект (window.__config) или правило порядка («вызывайте initAnalytics() до track()») связывают модули, между которыми нет ни одного импорта.
Слабая связность: у utils.ts с датами, ценами и правами доступа нет одной задачи, поэтому его импортируют все; у папки features/checkout такая задача есть.
Как измерить: madge --circular --extensions ts,tsx src или dependency-cruiser показывают циклы и кто что импортирует; PR одной фичи, который трогает десять папок, даёт самый дешёвый сигнал.
Наследование привязывает подкласс к внутреннему устройству родителя, поэтому изменение базового класса может сломать подклассы, которых он никогда не видел, а иерархия допускает только одну ось вариаций. Композиция держит части и пользуется их публичным API. Наследуйтесь, когда этого ждёт платформа: Error, HTMLElement.
Хрупкий базовый класс (fragile base class): базовый addAll() вызывает add(), поэтому CountingTagList, который переопределяет оба метода, считает каждый тег дважды. Ничто не предупреждает; версия на композиции считает верно, потому что пользуется только публичными методами.
Комбинации множатся: кэширование, логирование и повторы в виде подклассов требуют CachedLoggedClient и ему подобных; в виде обёрток они свободно сочетаются: withCache(withRetry(client)).
«Является» или «содержит» (is-a vs has-a): UserService, который extends HttpClient ради get(), открывает своим вызывающим все HTTP-методы; храните клиент в поле и делегируйте ему.
Когда наследование уместно: HttpError extends Error (ради instanceof и стека вызовов), кастомный элемент, error boundary, который extends Component, неглубокая абстрактная база с шаблонным методом.
SOLID объединяет пять правил проектирования: единственная ответственность, открытость/закрытость, подстановка Лисков, разделение интерфейсов и инверсия зависимостей. В TypeScript они относятся к модулям и функциям не меньше, чем к классам.
S, единственная ответственность (single responsibility): AuthService, который вызывает API, хранит токены и показывает тосты, меняется по трём причинам; разделите его на API-клиент, хранилище токенов и UI-код.
O, открытость/закрытость (open/closed): валидатор, который принимает список правил, получает maxLength новой функцией, а не ещё одним if в validate().
L, подстановка Лисков (Liskov substitution): подтип выполняет обещания родителя, а не только повторяет его сигнатуры: MemoryStorage для тестов возвращает null для отсутствующего ключа, как и localStorage.
I, разделение интерфейсов (interface segregation): readToken просит Pick<Storage, 'getItem'>, поэтому тест передаёт объект с одним методом.
D, инверсия зависимостей (dependency inversion): createCheckout зависит от интерфейса PaymentGateway, которым владеет сам; код платёжного провайдера его реализует, а точка входа приложения связывает их.
Предел: разделяйте код, который меняется по разным причинам, но не заводите интерфейс для каждого класса: одна реализация и ни одного фейка означают, что интерфейс пока не нужен.
Нарушен принцип подстановки Лисков: buyNow написан для любого Cart и ожидает, что checkout() создаст заказ, а GuestCart компилируется и затем бросает ошибку. Исправлять нужно модель, а не подкласс: пусть оформление заказа требует вошедшего пользователя, тогда для гостя такой вызов даже не написать.
Почему это компилируется: переопределение может принимать меньше параметров, а брошенная ошибка не входит в тип. Обещание «checkout создаёт заказ» живёт только в головах вызывающих.
Симптом: перед каждым оформлением появляются проверки if (cart instanceof GuestCart), что заодно нарушает открытость/закрытость: каждый новый вид корзины заставляет править их все.
Исправление: корзина только хранит товары, а checkout(cart, user, api) требует SignedInUser. Код без пользователя обрабатывает гостевой случай один раз, там, где о нём известно.
Другие нарушения LSP: исключение «не поддерживается», более строгая проверка входа, чем у родителя, null там, где родитель его никогда не возвращал, метод, который молча ничего не делает.
Паттерн Module прячет состояние в замыкании и открывает публичный API; Singleton гарантирует один экземпляр с одной глобальной точкой доступа. ES-модуль делает и то и другое: его область видимости верхнего уровня приватна, и он выполняется один раз, поэтому каждый импортирующий файл получает один и тот же экспортированный объект.
Старый паттерн Module: IIFE, которая возвращает объект; так писали библиотеки до ES-модулей.
Синглтон на классе (private constructor, статический instance) работает в TypeScript, но ничего не добавляет к export const analytics = ....
Ленивое создание: dbPromise ??= ... создаёт объект при первом использовании, для того, что дорого, асинхронно или существует только в браузере, например для подключения к IndexedDB.
У «один раз» есть границы: модуль выполняется один раз на разрешённый URL в одном realm; две копии пакета в node_modules, iframe или Web Worker получают каждый свой «синглтон».
Цена: зависимость не видна ни в одной сигнатуре, состояние переходит между тестами в одном файле, а на сервере один объект уровня модуля общий для всех запросов.
Обычное решение: создайте единственный экземпляр в точке входа приложения и передавайте его явно или через контекст; тесты создают свой.
Фабрикой называют функцию или метод, который создаёт объекты и возвращает их как интерфейс, поэтому вызывающий код никогда не называет конкретный класс. createApiClient(env) возвращает продакшен-клиент, клиент для стейджинга или фейк в памяти для тестов, и только фабрика знает, какой именно.
Решает одно место: switch по окружению живёт в фабрике; компоненты и хуки зависят только от ApiClient.
**TypeScript проверяет switch**: если Env объявлен объединением, а тип возврата указан явно, пропущенный case даёт ошибку «Function lacks ending return statement».
Factory Method, версия из GoF, означает метод, который подкласс переопределяет, чтобы выбрать объект, с которым работает базовый класс; во фронтенде вместо этого обычно передают функцию.
Abstract Factory создаёт семейство объектов, которые должны подходить друг к другу: createPlatform('desktop') возвращает хранилище и уведомления для Electron, createPlatform('web') их браузерные версии, так что они никогда не перемешаются.
В платформе: document.createElement('input'), axios.create(config), createRoot и createContext тоже фабрики.
Адаптер превращает один интерфейс в тот, которого ждёт ваш код, например DTO бэкенда или SDK в ваши собственные типы. Фасад ставит один простой интерфейс перед несколькими частями, например users.search() поверх URL, авторизации, JSON и ошибок. Первый меняет форму, второй прячет сложность.
Адаптер на границе: toUser(dto) один раз превращает user_id, full_name и строку ISO в id, name и Date; когда бэкенд переименует поле, поменяется одна функция, а не каждый компонент.
Адаптер вокруг SDK: оберните Amplitude или SDK карт в свой интерфейс Analytics с теми методами, которыми пользуетесь, и смена вендора или фейк в тестах затронет один файл.
Фасад: компоненты вызывают users.search('ada'); строка запроса, токен, проверка статуса, JSON и маппинг остаются за ним. Здесь фасад использует адаптер: такое сочетание встречается часто.
Признаки дырявой абстракции: компоненты всё ещё импортируют типы SDK или проверяют res.status, либо фасад повторяет каждую опцию того, что оборачивает, и становится простой прослойкой.
Прокси заменяет собой настоящий объект, имеет тот же интерфейс и управляет доступом к нему: создаёт объект лениво, кэширует результаты, проверяет права или перенаправляет вызовы. Паттерн обычно реализуют небольшим классом-обёрткой; Proxy в JavaScript даёт один из способов построить его обобщённо.
Виды: виртуальный (создать при первом использовании), кэширующий, защищающий (отказать в вызовах, которые пользователю не положены), удалённый (переслать вызов в воркер или на сервер).
Прокси и декоратор: структура та же, цель другая: декоратор добавляет поведение вокруг каждого вызова, а прокси решает, когда и дойдёт ли вызов до настоящего объекта.
Где встречается: React.lazy служит виртуальным прокси для компонента, Service Worker стоит сетевым прокси перед fetch, а wrap(worker) из Comlink делает Web Worker похожим на локальный объект.
**Класс обычно лучше Proxy**: он типизирован, легко отлаживается и ничего не добавляет к стоимости чтения свойства, а Proxy запускает ловушку при каждом обращении и ломает #private-поля и идентичность по ===.
**Proxy уместен**, когда методы нельзя перечислить заранее: удалённые вызовы (Comlink, клиент tRPC), реактивность (Vue, MobX), черновики Immer.
Декоратор оборачивает объект или функцию в другой объект с тем же интерфейсом и добавляет поведение вокруг вызова: логирование, замер времени, кэширование, повторы. В TypeScript его обычно пишут функцией высшего порядка; синтаксис @decorator (TypeScript 5.0+, из предложения TC39) делает то же для членов класса.
Весь смысл в том же интерфейсе: withTiming('loadUser', fn) сохраняет сигнатуру fn, поэтому вызывающие не меняются, а обёртки складываются: withLogging(withRetry(fetchJson)).
Декораторы TC39: декоратор метода получает метод и ClassMethodDecoratorContext (name, kind, addInitializer) и возвращает замену. Для целей ниже esnext TypeScript компилирует @logged в обычные вызовы функций.
Два несовместимых вида: NestJS и TypeORM используют старую форму experimentalDecorators (target, key, descriptor) с декораторами параметров и метаданными, которых в форме TC39 нет.
В React эту роль играет компонент высшего порядка: withAuth(Page) возвращает компонент с теми же пропсами и проверкой сверху.
В Observer субъект сам хранит список наблюдателей и вызывает их, поэтому каждый наблюдатель подписывается на конкретный объект. В publish/subscribe между ними стоит брокер или канал: издатели и подписчики знают только имя темы или канала, но не друг друга.
Observer в платформе: addEventListener на элементе, IntersectionObserver и ResizeObserver (названы в честь паттерна), subscribe у стора Redux или Zustand, Observable из RxJS. React читает любой из них через useSyncExternalStore.
Pub/sub в платформе: BroadcastChannel между вкладками, postMessage между фреймами, темы поверх WebSocket, шина событий на всё приложение; издатель не знает, слушает ли его кто-нибудь.
Компромисс: pub/sub развязывает сильнее, и это нужно между вкладками и микрофронтендами; внутри одного приложения он прячет поток: никто не найдёт, кто реагирует на cart:updated, а опечатка в имени темы молча ничего не делает.
У каждой подписки должен быть выход: subscribe возвращает функцию отписки или Subscription; слушатели, которых никогда не удаляют, дают самый частый баг этого паттерна.
bind создаёт новую функцию при каждом вызове, поэтому off получает функцию, которую никогда не регистрировали, и ничего не удаляет. Стор продолжает вызывать render у каждой когда-либо открытой панели, а его список слушателей держит в памяти эти панели и их удалённые элементы.
Слушателей сравнивают по идентичности: EventTarget, эмиттер Node и большинство сторов удаляют слушателя, который === переданному, а this.render.bind(this) !== this.render.bind(this). removeEventListener со свежей стрелочной функцией ломается так же.
Симптом растёт: откройте и закройте панель три раза, и одно изменение стора вызовет три рендера в отсоединённые элементы.
Исправление 1: регистрируйте функцию со стабильной идентичностью, стрелочное поле класса, привязанное один раз на экземпляр, и передавайте в off ту же ссылку.
Исправление 2, надёжнее: пусть on возвращает функцию отписки, а вы её сохраняете, тогда сравнивать нечего. Для DOM-слушателей передайте { signal } от AbortController и вызовите abort().
В React отпиской служит очистка эффекта (return store.subscribe(...)), а useSyncExternalStore делает это за вас.
Медиатор координирует группу объектов, которые иначе вызывали бы друг друга: каждый сообщает медиатору о своих изменениях, а медиатор решает, кому ещё нужно измениться. Observer только сообщает, что что-то произошло; медиатор хранит правила взаимодействия частей.
Зачем: пяти полям, которые влияют друг на друга, нужно до 20 прямых связей; с медиатором каждое знает один объект, а правила (без экспресс-доставки за границу, номер плательщика НДС только для компаний) лежат в одном месте и тестируются без UI.
В React медиатором обычно служит ближайший общий родитель, который поднимает состояние, или редьюсер: дети получают значения, сообщают об изменениях через колбэки и никогда не вызывают друг друга.
Вместе с Observer: части часто уведомляют медиатора событиями, а он решает, что дальше. В pub/sub каждый подписчик сам решает, как реагировать; медиатор собирает координацию в одном месте.
Риск: медиатор на всё приложение превращается в god object. Держите по одному на поток: форму, страницу, мастер.
Strategy делает алгоритм заменяемым: вызывающий передаёт, как считать цену, сортировать или валидировать. Command превращает запрос в значение, поэтому его можно поставить в очередь, записать в лог, повторить или отменить. С функциями первого класса стратегия становится функцией, а команда функцией или обычным объектом.
Стратегии, которыми вы уже пользуетесь: компаратор в array.sort(compare), опции retry и retryDelay в TanStack Query, словарь правил для цен или доставки.
**Почему это лучше switch**: новый способ доставки добавляет одну запись, а не правку в каждой функции, которая ветвится по типу; satisfies Record<string, ShippingStrategy> типизирует каждую запись, а keyof typeof shipping выводит допустимые ключи.
Команды, которыми вы уже пользуетесь: реестр команд редактора за палитрой команд и горячими клавишами (VS Code, editor.chain().focus().toggleBold().run() в Tiptap), офлайн-очередь (outbox), которую проигрывают при восстановлении связи.
Данные или поведение: команду в виде обычных данных можно сохранить в IndexedDB и проиграть после перезагрузки; объект с методом execute() так не сохранить, зато рядом с ним можно держать undo().
Многие паттерны сводятся к возможности языка: Strategy и Command становятся функциями, Singleton и Module превращаются в ES-модуль, Iterator в протокол итерации. Adapter, Facade, Proxy и Mediator по-прежнему требуют проектирования, потому что они решают, как части соединяются.
Функции: Strategy, Command и Template Method сводятся к передаче колбэка: sort(compare), retry(fn, { shouldRetry }), проп renderItem.
Встроено: роль Iterator играют [Symbol.iterator], генераторы и for...of, роль Observer играет EventTarget.
Prototype: Object.create(proto) и structuredClone и есть этот паттерн, а наследование в самом языке прототипное.
Builder: объект опций со значениями по умолчанию заменяет большинство билдеров; текучие (fluent) билдеры остаются там, где цепочка хорошо читается или тип растёт шаг за шагом: запросы Kysely, z.string().min(3) в Zod.
Chain of Responsibility и Decorator: массив middleware, как в Redux (store => next => action), интерсепторах axios и Hono, плюс функции высшего порядка.
Пусть бизнес-код владеет нужным ему интерфейсом (портом вроде OrdersGateway в своих типах), а код для HTTP, GraphQL или памяти пишется адаптерами, которые его реализуют. Тогда домен никогда не импортирует fetch, axios или DTO бэкенда, а адаптер выбирает точка входа.
Инверсия касается направления импортов
Без неё orders/cancelOrder.ts импортирует api/httpOrders.ts. С ней orders/model.ts объявляет порт, а api/httpOrders.ts импортирует его: импорт идёт от детали к политике, против направления вызовов.
Внедрение (injection) отвечает лишь за то, как экземпляр попадает внутрь (параметр, аргумент фабрики, контекст React); внедрение конкретного HttpOrdersApi означает внедрение без инверсии.
Что где лежит
Порт: доменные типы и ошибки (Order с totalCents и Date, OrderNotFound); никакого Response, кодов статуса или snake_case.
Адаптер: URL, заголовки, маппинг DTO и превращение 404 в OrderNotFound.
Корень композиции (composition root): main.ts или корневые провайдеры создают createHttpOrders(baseUrl); тесты и Storybook передают адаптер в памяти.
Когда это того не стоит
CRUD-экран, который показывает то, что вернул API, получает только лишний слой: TanStack Query плюс клиент, сгенерированный из OpenAPI, проще. Инвертируйте там, где есть логика, которую стоит защищать (оформление заказа, цены, офлайн-синхронизация), или несколько источников данных.
Порт не заменяет кэш запросов: queryFn вызывает гейтвей, а кэширование и повторные запросы остаются в библиотеке.
Обычная тройка: god object, который читают и пишут все фичи, папка utils без единого назначения и общий компонент, подогнанный флагами под случаи, для которых его не делали. Рефакторьте маленькими шагами под тестами, по одной ответственности за раз, сохраняя старую точку входа, пока вызывающие не переедут.
God store или god service
Признаки: appStore или ApiService на 2000 строк, который импортирует каждая фича и в котором на каждом PR возникают конфликты слияния.
Выход: разделите его по доменам (cart, session), перенесите серверные данные в кэш запросов (часто они составляют половину такого стора), а старый модуль пусть реэкспортирует новые части, пока вызывающие переезжают; затем удалите его.
Свалка utils
Признаки: в utils.ts лежат formatDate, isAdmin и calculateTax; его импортируют все, поэтому через него образуются циклы импортов.
Выход: бизнес-правила возвращаются в свою фичу (checkout/tax.ts), общий код уходит в shared/lib по файлу на тему, а самописные хелперы уступают место Intl.NumberFormat, structuredClone и Object.groupBy.
Неверная абстракция
Признаки: общий <DataTable> с 30 пропсами и if (variant === 'legacy') внутри, который никто не решается трогать.
Выход: встройте его обратно в вызывающих, дайте копиям различаться, затем вынесите то, что действительно общее.
Чтобы результат закрепился
Сначала покройте поведение тестами, рефакторьте вместе с работой над фичами и добавьте правило no-restricted-imports для старого модуля, чтобы паттерн не вернулся.