Если вы хоть немного писали код на TypeScript, особенно в среде с реальными задачами и коллегами, то, скорее всего, сталкивались с запутанными классами, непредсказуемыми зависимостями и странным поведением при доработках. Один неосторожный шаг — и вроде бы безобидное изменение ломает половину проекта. Это обычная история в жизни любого разработчика.
Чтобы таких ситуаций было меньше, существует набор принципов под названием SOLID. Это не фреймворк, не библиотека и даже не панацея. Это всего лишь рекомендации, которые помогают писать код, с которым потом не стыдно работать — неважно, вам или кому-то ещё.
Принципы SOLID пришли из объектно-ориентированного программирования, но отлично применимы в TypeScript, особенно когда вы работаете с классами, интерфейсами, типами и слоями логики. Они помогают:
- делать код гибким для изменений, но устойчивым к поломкам;
- разделять ответственность так, чтобы каждая часть делала что-то одно, но хорошо;
- писать тестируемый код;
- уменьшать зависимость между модулями.
Принципы SOLID
S — Single Responsibility Principle (Принцип единственной ответственности)
Почему это важно?
Когда один класс делает всё сразу — например, и обрабатывает заказы, и валидирует формы, и сохраняет данные, и пишет в лог — он становится хрупким и неуправляемым. Любое изменение в одной части логики может ненароком сломать другую.
Разделяя ответственность, вы получаете:
- читаемый и понятный код;
- простую отладку;
- возможность переиспользовать части логики;
- более надёжные изменения: вы уверены, что затрагиваете только нужную часть.
Пример без SRP (антипаттерн)
class OrderService {
createOrder(orderData: any) {
// 1. Валидация
if (!orderData.productId || orderData.quantity <= 0) {
throw new Error("Invalid data");
}
// 2. Сохранение в базу
console.log("Saving order to DB...");
// 3. Отправка письма
console.log("Sending confirmation email...");
}
}
Этот OrderService делает три вещи:
- валидирует заказ;
- работает с хранилищем;
- отправляет почту.
Изменится логика валидации? Нужно лезть сюда. Изменится SMTP-сервер? Тоже сюда. А теперь представьте, что этим методом пользуются десятки мест в коде. Опасно.
Пример с SRP (правильный подход)
class OrderValidator {
static validate(orderData: any): void {
if (!orderData.productId || orderData.quantity <= 0) {
throw new Error("Invalid data");
}
}
}
class OrderRepository {
save(orderData: any): void {
console.log("Saving order to DB...");
}
}
class EmailService {
sendConfirmationEmail(orderData: any): void {
console.log("Sending confirmation email...");
}
}
class OrderService {
constructor(
private repository: OrderRepository,
private emailService: EmailService
) {}
createOrder(orderData: any): void {
OrderValidator.validate(orderData);
this.repository.save(orderData);
this.emailService.sendConfirmationEmail(orderData);
}
}
Теперь у нас:
- OrderValidator — проверяет данные;
- OrderRepository — сохраняет в хранилище;
- EmailService — рассылает письма;
- OrderService — просто связывает эти кусочки.
Если нужно поменять способ хранения или формат валидации — меняется только нужный модуль, остальные не страдают.
В реальности
Не обязательно всё дробить на миллионы классов — важно осознанно отделять логические зоны ответственности. Иногда достаточно просто вынести функции в отдельные файлы или модули, и уже станет легче.
O — Open/Closed Principle (Принцип открытости/закрытости)
Пример нарушения OCP
Допустим, у вас есть расчёт стоимости доставки:
class DeliveryCostCalculator {
calculate(type: string, weight: number): number {
if (type === "regular") {
return weight * 10;
} else if (type === "express") {
return weight * 20;
} else {
throw new Error("Unknown delivery type");
}
}
}
Работает? Да. А теперь пришёл новый тип доставки — "pickup". Придётся лезть внутрь класса и править if. Это нарушение принципа.
Решение с OCP — через полиморфизм
interface DeliveryStrategy {
calculate(weight: number): number;
}
class RegularDelivery implements DeliveryStrategy {
calculate(weight: number): number {
return weight * 10;
}
}
class ExpressDelivery implements DeliveryStrategy {
calculate(weight: number): number {
return weight * 20;
}
}
class PickupDelivery implements DeliveryStrategy {
calculate(weight: number): number {
return 0; // бесплатно
}
}
class DeliveryCostCalculator {
constructor(private strategy: DeliveryStrategy) {}
calculate(weight: number): number {
return this.strategy.calculate(weight);
}
}
Использование:
const express = new DeliveryCostCalculator(new ExpressDelivery());
console.log(express.calculate(2)); // 40
const pickup = new DeliveryCostCalculator(new PickupDelivery());
console.log(pickup.calculate(5)); // 0
Что мы выиграли?
- Не трогаем старые классы;
- Добавляем новый тип доставки — просто создаём класс;
- Упрощается тестирование;
Поведение изолировано: если баг, он только в конкретной стратегии.
А если без классов?
В TypeScript часто удобнее использовать функции и объекты:
type DeliveryStrategy = (weight: number) => number;
const strategies: Record<string, DeliveryStrategy> = {
regular: (w) => w * 10,
express: (w) => w * 20,
pickup: (_) => 0,
};
function calculateDelivery(type: string, weight: number): number {
const strategy = strategies[type];
if (!strategy) throw new Error("Unknown delivery type");
return strategy(weight);
}
Та же идея: расширяете объект strategies, не трогая логику функции calculateDelivery.
L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
Если коротко: объект подкласса должен спокойно заменять объект родительского класса, не ломая поведение. То есть, если у вас есть код, который работает с базовым классом, то он должен также работать с любым его наследником — без сюрпризов.
Что это значит на практике?
Если вы создаёте класс, который наследуется от другого, не нужно:
- менять смысл методов;
- выбрасывать исключения, где родитель их не выбрасывал;
- делать методы “заглушками”;
- нарушать логические ожидания.
Если это происходит — скорее всего, ваш подкласс не подходит для такого наследования
Плохой пример: нарушение LSP
class Bird {
fly() {
console.log("Птица летит");
}
}
class Duck extends Bird {}
class Penguin extends Bird {
fly() {
throw new Error("Пингвины не умеют летать");
}
}
function makeBirdFly(bird: Bird) {
bird.fly();
}
Выглядит логично — Penguin тоже птица. Но как только вы вызываете makeBirdFly(new Penguin()), программа падает с ошибкой. Это нарушение принципа Лисков: наследник (Penguin) не может быть подставлен туда, где ожидается родитель (Bird), без изменения логики.
Как исправить? Через разделение абстракций
interface FlyingBird {
fly(): void;
}
interface Bird {
eat(): void;
}
class Duck implements Bird, FlyingBird {
eat() {
console.log("Утка ест хлеб");
}
fly() {
console.log("Утка летит");
}
}
class Penguin implements Bird {
eat() {
console.log("Пингвин ест рыбу");
}
}
📢 Подписывайтесь на наш Telegram-канал.
Там вы найдете анонсы обучающих статей и видео, готовый код для ваших проектов и увлекательные курсы. Ничего лишнего — только практика, вдохновение и развитие.
Теперь мы не делаем ложных предположений: если птица умеет летать — она реализует интерфейс FlyingBird. Если не умеет — просто Bird.
Использование:
function makeItFly(bird: FlyingBird) {
bird.fly();
}
makeItFly(new Duck()); // ✅ Работает
// makeItFly(new Penguin()); // ❌ Ошибка на этапе компиляции
Пример ближе к реальности: формы и сохранение
class Form {
save() {
console.log("Сохраняем данные формы");
}
}
class ReadOnlyForm extends Form {
save() {
throw new Error("Нельзя сохранить: форма только для чтения");
}
}
Кто-то напишет такой код в надежде переиспользовать Form, но при вызове save() поведение неожиданное. Лучше сделать ReadOnlyForm не наследником, а отдельной сущностью, где метода save просто нет.
I — Interface Segregation Principle (Принцип разделения интерфейсов)
В чём проблема?
Иногда разработчики делают огромные интерфейсы “на всякий случай” — вдруг пригодится. Но когда вы начинаете использовать такой интерфейс, приходится реализовывать методы, которые не имеют смысла в вашем контексте.
В итоге:
- появляется “мёртвый” код;
- нарушается смысл;
- растёт технический долг.
Пример плохого интерфейса (нарушение ISP)
interface Worker {
work(): void;
eat(): void;
}
class HumanWorker implements Worker {
work() {
console.log("Человек работает");
}
eat() {
console.log("Человек ест");
}
}
class RobotWorker implements Worker {
work() {
console.log("Робот работает");
}
eat() {
// 🤨 Роботы не едят — а метод реализовать приходится
throw new Error("Робот не ест");
}
}
Выглядит нелепо. Зачем RobotWorker метод eat, если он ему не нужен?
Как исправить? Разделить интерфейс
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class HumanWorker implements Workable, Eatable {
work() {
console.log("Человек работает");
}
eat() {
console.log("Человек ест");
}
}
class RobotWorker implements Workable {
work() {
console.log("Робот работает");
}
}
Теперь каждый реализует только то, что относится к нему. Интерфейсы — узкие и осмысленные.
Пример из жизни: API клиента
interface APIClient {
get(): void;
post(): void;
put(): void;
delete(): void;
}
Кажется логично, но что если вы делаете ReadOnlyApiClient?
class ReadOnlyApiClient implements APIClient {
get() {
console.log("Получаем данные");
}
post() {
throw new Error("Метод POST запрещён");
}
put() {
throw new Error("Метод PUT запрещён");
}
delete() {
throw new Error("Метод DELETE запрещён");
}
}
Такое использование — сигнал: интерфейс слишком широкий.
Разделяем правильно:
interface Gettable {
get(): void;
}
interface Postable {
post(): void;
}
interface Puttable {
put(): void;
}
interface Deletable {
delete(): void;
}
class ReadOnlyApiClient implements Gettable {
get() {
console.log("Получаем данные (только чтение)");
}
}
Теперь клиент использует только нужный набор интерфейсов. Остальной код становится понятнее, жёстче типизируется, и легче тестируется.
D — Dependency Inversion Principle (Принцип инверсии зависимостей)
Что это значит?
Вместо того чтобы класс сам создавал и настраивал свои зависимости (например, логгер, репозиторий, почтовый сервис), он получает их извне — в виде интерфейсов или абстракций.
Это даёт вам:
- лёгкую подмену реализаций (например, для тестов);
- слабую связанность между частями приложения;
- более чистый и расширяемый код.
Плохой пример: жёсткая зависимость
class NotificationService {
private emailClient = new EmailClient(); // ⚠ жёсткая связка
send(message: string) {
this.emailClient.sendEmail(message);
}
}
Если вдруг вам понадобится логгер вместо почты, или мок для тестов — придётся лезть внутрь NotificationService. Это нарушение DIP.
Хороший пример: через абстракцию
interface Notifier {
send(message: string): void;
}
class EmailClient implements Notifier {
send(message: string): void {
console.log("📧 Отправляем по email:", message);
}
}
class LoggerNotifier implements Notifier {
send(message: string): void {
console.log("📝 Логируем сообщение:", message);
}
}
class NotificationService {
constructor(private notifier: Notifier) {}
notifyUser(message: string) {
this.notifier.send(message);
}
}
Теперь NotificationService зависит не от конкретного клиента, а от абстракции — интерфейса Notifier.
Использование:
const emailService = new NotificationService(new EmailClient());
emailService.notifyUser("Ваш заказ оформлен");
const logService = new NotificationService(new LoggerNotifier());
logService.notifyUser("Тестовое уведомление");
Вы легко можете заменить реализацию на любую нужную — без изменений в NotificationService.
А что насчёт тестов?
Теперь можно просто передать заглушку:
class StubNotifier implements Notifier {
send(message: string): void {
console.log("🔧 [Заглушка] Сообщение отправлено:", message);
}
}
const testService = new NotificationService(new StubNotifier());
testService.notifyUser("Тестовое сообщение"); // не уходит никуда, но поведение проверено
Комментарии
0