Тема чистого кода — одна из самых обсуждаемых в сообществе разработчиков. Это не удивительно: от качества кода напрямую зависят скорость разработки, легкость поддержки и масштабирования проекта. В мире JavaScript и TypeScript, с их гибкостью и динамической природой, следование принципам чистого кода становится особенно важным.

В этой статье мы разберем несколько ключевых принципов написания чистого, идиоматичного кода на TypeScript, которые помогут сделать ваши проекты более предсказуемыми, читаемыми и профессиональными. Погнали!

1. Избегайте избыточного контекста в именах

Проблема: Если имя типа или класса уже говорит само за себя, не нужно повторять эту информацию в именах его свойств. Это избыточно и создает лишний шум.

Плохо: Дублирование car в каждом свойстве.

type Car = {
  carMake: string;
  carModel: string;
  carColor: string;
}

function printCar(car: Car): void {
  console.log(`${car.carMake} ${car.carModel} (${car.carColor})`);
}

Хорошо: Имя типа Car уже задает контекст.

type Car = {
  make: string;
  model: string;
  color: string;
}

function printCar(car: Car): void {
  console.log(`${car.make} ${car.model} (${car.color})`);
}

Что изменилось: Код стал короче, читабельнее, а его смысл не изменился. Мы убрали ненужный префикс car, так как он уже содержится в имени типа.

2. Используйте enum

Проблема: Часто нам важна не конкретная строка или число, а сам факт выбора из предопределенного набора значений. Использование простых объектов для этого — не лучшая практика, так как они не предоставляют строгой типизации.

Плохо: Использование объекта как псевдо-enum.

const GENRE = {
  ROMANTIC: 'romantic',
  DRAMA: 'drama',
  COMEDY: 'comedy',
  DOCUMENTARY: 'documentary',
}

projector.configureFilm(GENRE.COMEDY);
// В методе configureFilm придется делать проверки на строки

Хорошо: Использование enum.

enum Genre {
  Romantic,
  Drama,
  Comedy,
  Documentary,
}

projector.configureFilm(Genre.Comedy);

class Projector {
  configureFilm(genre: Genre) {
    switch (genre) {
      case Genre.Romantic:
        // Логика для романтического фильма
        break;
    }
  }
}

Что изменилось: enum в TypeScript создает новый тип и гарантирует, что в configureFilm будет передано только одно из допустимых значений. Код становится самодокументируемым и менее подвержен ошибкам, связанным с опечатками в строках.

Примечание: Для строковых enum можно использовать синтаксис enum Genre { Romantic = 'romantic' }, если нужно сериализовать значение в строку.

3. Имена функций должны отражать их суть

Проблема: Расплывчатые имена функций заставляют читателя гадать, что же именно они делают. Это замедляет понимание кода и может привести к ошибкам.

Плохо: Что такое «add»? Дни? Месяцы? Годы?

function addToDate(date: Date, month: number): Date {
  // ...
}

const date = new Date();
addToDate(date, 1); // Неочевидно, что добавляется

Хорошо: Имя функции четко описывает производимое действие.

function addMonthToDate(date: Date, month: number): Date {
  // ...
}

const date = new Date();
addMonthToDate(date, 1);

Что изменилось: Теперь любой разработчик, увидев вызов функции, мгновенно поймет, что к дате добавляется определенное количество месяцев. Не экономьте на словах в именах функций!

4. Функциональный стиль предпочтительнее императивного

Проблема: Императивные циклы (forwhile) для агрегации данных часто требуют ручного управления промежуточным состоянием (например, переменной-счетчиком totalOutput), что усложняет код и повышает вероятность ошибок.

Плохо: Императивный подход с циклом и изменяемой переменной.

const contributions = [/* ... массив объектов с полем linesOfCode ... */];
let totalOutput = 0;

for (let i = 0; i < contributions.length; i++) {
  totalOutput += contributions[i].linesOfCode;
}

Хорошо: Декларативный подход с методом reduce.

const contributions = [/* ... массив объектов с полем linesOfCode ... */];

const totalOutput = contributions
  .reduce((totalLines, contribution) => totalLines + contribution.linesOfCode, 0);

Что изменилось: Мы избавились от изменяемого состояния (let totalOutput). Код стал короче, выразительнее и сфокусирован на что мы хотим сделать (посчитать сумму), а не на как это сделать (инициализировать счетчик, перебрать индексы, прибавить значение). Методы mapfilterreduce — ваши лучшие друзья.

5. Избегайте негативных проверок в именах функций

Проблема: Когда имя функции содержит отрицание (например, Not), это заставляет наш мозг выполнять лишнюю логическую операцию при чтении условия. Условие if (isEmailNotUsed(email)) требует мысленно преобразовать «если email НЕ используется» в «если email свободен». Прямое утверждение (isEmailUsed) читается и понимается легче.

Плохо: Отрицание в имени функции. Логика условия if (isEmailNotUsed(email)) становится менее интуитивной.

function isEmailNotUsed(email: string): boolean {
  // ...
}

if (isEmailNotUsed(email)) {
  // ...
}

Хорошо: Позитивное утверждение в имени функции. Условие с явным отрицанием if (!isEmailUsed(email)) проще для восприятия.

function isEmailUsed(email: string): boolean {
  // ...
}

if (!isEmailUsed(email)) {
  // ...
}

Что изменилось: Условие if (!isEmailUsed(email)) читается проще, чем исходный вариант. Мы проверяем, что email «не использован», что интуитивно понятнее, чем «если не-не-использован». Старайтесь, чтобы булевы функции возвращали true для ожидаемого, позитивного условия.

6. Иммутабельность

Проблема: Изменяемые структуры данных могут быть неожиданно изменены в разных частях программы, что приводит к трудноотлавливаемым багам.

Плохо: Все свойства конфигурации можно изменить в runtime.

interface Config {
  host: string;
  port: string;
  db: string;
}

const config: Config = {...};
config.db = 'new_db'; // Потенциально нежелательное изменение

Хорошо: Использование readonly для защиты от изменений.

interface Config {
  readonly host: string;
  readonly port: string;
  readonly db: string;
}

const config: Config = {...};
config.db = 'new_db'; // Ошибка компиляции: Cannot assign to 'db' because it is a read-only property.

Что изменилось: Компилятор TypeScript теперь не позволит изменить свойства Config после их первоначального задания. Это делает код предсказуемее и защищает от случайных мутаций. Для объектов и массивов можно также использовать утилиты типов Readonly<T> и ReadonlyArray<T>.

7. type vs interface — осознанный выбор

Проблема: В TypeScript и type, и interface можно использовать для описания форм объектов, но у них есть важные различия.

Не строго плохо, но часто не оптимально:

interface EmailConfig { ... }
interface DbConfig { ... }
interface Config { ... } // Композиция через extends?

type Shape = { ... } // А здесь нужен interface, если будут классы-имплементаторы.

Хорошо: Используйте правильный инструмент для задачи.

// type для композиции (объединения или пересечения типов)
type EmailConfig = { ... }
type DbConfig = { ... }
type Config = EmailConfig | DbConfig; // Config - это либо один, либо другой

// interface для ООП-иерархий (extends/implements)
interface Shape {
  area(): number;
}

class Circle implements Shape {
  area() { ... }
}

class Square implements Shape {
  area() { ... }
}

Что изменилось: Есть простое правило:

  • Используйте type, когда вам могут понадобиться объединения (|), пересечения (&) или вы описываете тип-примитив.

  • Используйте interface, если вы хотите использовать классическое ООП с extends или implements. Интерфейсы лучше работают с ошибками в IDE.

Строгого правила нет, но главное — быть последовательным в рамках проекта.

8. Один концепт — один тест

Проблема: Большие тесты, проверяющие множество сценариев сразу, трудно читать и отлаживать. Если такой тест падает, не сразу понятно, какое именно условие не выполнилось.

Плохо: Три разных сценария в одном тесте.

it('handles date boundaries', () => { // Тестирует ВСЕ?
  let date: AwesomeDate;

  date = new AwesomeDate('1/1/2015');
  assert.equal('1/31/2015', date.addDays(30));

  date = new AwesomeDate('2/1/2016');
  assert.equal('2/29/2016', date.addDays(28));

  date = new AwesomeDate('2/1/2015');
  assert.equal('3/1/2015', date.addDays(28));
});

Хорошо: Разделение на три независимых теста.

it('should handle 30-day months', () => {
  const date = new AwesomeDate('1/1/2015');
  assert.equal('1/31/2015', date.addDays(30));
});

it('should handle leap year', () => {
  const date = new AwesomeDate('2/1/2016');
  assert.equal('2/29/2016', date.addDays(28));
});

it('should handle non-leap year', () => {
  const date = new AwesomeDate('2/1/2015');
  assert.equal('3/1/2015', date.addDays(28));
});

Что изменилось: Каждый тест проверяет ровно одну вещь и имеет четкое, понятное имя. Если один из сценариев упадет, мы сразу увидим, какой именно, по имени упавшего теста. Это следует принципу единственной ответственности (SRP), примененному к модульным тестам.

Заключение

Чистый код — это не догма, а набор практик, которые делают жизнь разработчика и всей команды проще. TypeScript с его мощной системой типов предоставляет отличные возможности для написания такого кода.

Конечно, в одной статье не уместить все аспекты чистого кода. Это огромная и интересная тема, включающая принципы SOLID, паттерны проектирования и многое другое.

А какие принципы чистого кода в TypeScript используете вы? Делитесь вашими любимыми практиками и примерами в комментариях!

Комментарии (0)


  1. sk_leks
    19.09.2025 08:45

    Я бы добавил еще в дополнение к 7 пункту немаловажный аспект про слияние деклараций и возможность повтроного объявления с тем же именем у интерфейса, что часто используется к примеру для расширения базовых интерфейсов у сторонних библиотек или отдельных приложений.

    interface User {
      id: number;
    }
    
    interface User {
      name: string;
    }
    
    // В результате слияния двух интерфейсов: { id: number; name: string }
    const user: User = { id: 1, name: "Nikita" };


  1. monochromer
    19.09.2025 08:45

    А здесь нужен interface, если будут классы-имплементаторы.

    Делать реализацию от типа тоже можно


  1. Rsa97
    19.09.2025 08:45

    Теперь любой разработчик, увидев вызов функции, мгновенно поймет, что к дате добавляется определенное количество месяцев

    Ну вит я увидел addMonthToDate и начал думать, а как к дате добавить месяц, например январь. Как минимум addMonthsToDate. А, возможно, Date.addInterval(interval: DateInterval), где

    interface DateInterval {
      days?: number,
      months?: number,
      years?: number,
    };
    


  1. Vitaly_js
    19.09.2025 08:45

    Попробую дать некоторую обратную связь.

    Хорошо: Использование enum.

    А я вот наоборот стараюсь enum не использовать. Смысла в нем не очень мнного, а вот при поддержке кода проблем больше.

    Рассмотрим пример:

    Скрытый текст
    enum Genre {
      Romantic = 'romantic',
      Drama = 'drama',
      Comedy = 'comedy',
      Documentary = 'documentary',
    }
    
    class Projector {
      configureFilm(genre: Genre) {
        switch (genre) {
          case Genre.Romantic:
            // Логика для романтического фильма
            break;
        }
      }
    }
    
    const projector = new Projector
    
    projector.configureFilm(Genre.Comedy);

    А теперь навалим жира, что бы Genre так же использовалась в свойстве объекта

    type UsefullData = {
      value: Genre
    }

    Подобное использование изначального enum вполне обычное явление. А проблемы начинаются, когда необходимо создавать для различных целей литералы этих данных.

    const getRomanticDataMock = () => ({
      value: 'romantic'
    }) satisfies UsefullData

    Тут будет ошибка:

    Type '"romantic"' is not assignable to type 'Genre'.(2322)

    И если таких свойств много придется тащить зависимости от Genre

    const getRomanticDataMock = () => ({
      value: Genre.Romantic
    }) satisfies UsefullData
    
    projector.configureFilm(getRomanticDataMock().value)

    А вот если отказаться от enum такой ситуации легко избежать.

    Скрытый текст
    const Genre = {
      Romantic: 'romantic',
      Drama: 'drama',
      Comedy: 'comedy',
      Documentary: 'documentary',
    } as const
    
    type Genres = (typeof Genre)[keyof typeof Genre]
    
    class Projector {
      configureFilm(genre: Genres) {
        switch (genre) {
          case Genre.Romantic:
            // Логика для романтического фильма
            break;
        }
      }
    }
    
    const projector = new Projector
    
    projector.configureFilm(Genre.Comedy);
    
    type UsefullData = {
      value: Genres
    }
    
    const getRomanticDataMock = () => ({
      value: 'romantic'
    }) satisfies UsefullData
    
    projector.configureFilm(getRomanticDataMock().value)

    Что изменилось: Мы избавились от изменяемого состояния (let totalOutput). Код стал короче, выразительнее и сфокусирован на что мы хотим сделать (посчитать сумму), а не на как это сделать (инициализировать счетчик, перебрать индексы, прибавить значение).

    По моему, тут вы перемудрили.

    Вам же ничто не мешает сделать так:

    const contributions = [{ linesOfCode: 100 }];
    let totalOutput = 0;
    
    for (const contribution of contributions) {
      totalOutput += contribution.linesOfCode;
    }

    И чем больше опереций будет в теле цикла, тем меньше вы будете экономить места в процентном соотношении. Да и выразительность даже в данном случае у каждого подхода плюс минус одна и тажа.

    Хорошо: Использование readonly для защиты от изменений.

    Это конечно хорошо, но делать сразу все readonly не всегда удобно. Например, вам ничто не мешает сделать так:

    interface Config {
      host: string;
      port: string;
      db: string;
    }
    
    const config = {
      host: 'myhost',
      port: '100',
      db: 'mydb'
    } as const satisfies Config

    Таким образом где надо у вас всегда будут ридоли конфиги, а в других местах с ними можно будет без доп. телодвижений работать.

    Используйте interface, если вы хотите использовать классическое ООП с extends или implements. Интерфейсы лучше работают с ошибками в IDE.

    Вот тут не очень понял мысль. Вам ничто не мешает с extends или implements использовать объектный type. Если ваши данные - это всегда какой-то объект, то совершенно спокойно можно его описывать и через type, и через interface. Все различия начнут появляться только в специфических случаях, т.е. когда мы хотим расширить объектный тип определенный в другом месте. Вот тогда придется использовать interface, просто потому что type на это не способен.


    1. PML Автор
      19.09.2025 08:45

      Благодарю, за отличный разворот. Рад, что есть разработчики, которые делятся и подмечают кейсы, делятся своим опытом и конструктивным мнением.


    1. PastuhovZahar
      19.09.2025 08:45

      У меня к enum примерно такое же сложилось отношение, приведенный пример очень в тему


  1. VladVR
    19.09.2025 08:45

    1. про избыточный контекст в именах - то что тупо дублируется имя типа в каждом первом поле это может и не очень хорошо, но совет в целом звучит как будто "называйте поля максимально кратко". Кроме этого, есть два поля, которые наоборот лучше дублировать, это id и name. Чтобы при сериализации мы получали объекты { personId: 1 } и { companyName: "name" }. Так и удобнее дебажить и снизит вероятность ошибок подставления id от не той сущности.

    2. негативы в именах функций - вредный совет. Как раз наоборот, если по бизнесу оно звучит негативно, надо и в коде писать негативно и наоборот избегать булевого инвертирования. Проблема в том, что инвертирование не всегда корректно, и при нём легко допустить ошибку. К примеру "у нас должен быть unchecked что то" люди инвертируют в "!(у нас есть checked что то)". И вот у вас на руках ошибка: когда у вас на экране нет ни checked ни unchecked, первый вариант корректно уронит тест, второй скажет что тест зелёный и допустит крэш в релиз. Эта проблема как то по научному называется, не помню где чита. Инверсией вы можете по недосмотру сделать false negative вместо false positive. Где false positive это "задержать не-террориста", т.е. потом придется перед человеком извиняться, а false negative "!(задержать террориста)", где погибнут люди.

    3. type vs interface - система типов в ts это отдельный декларативный язык программирования и type в нём это тоже что и const в обычном разделе. К примеру function myFunc() {} можно записать в виде const myFunc = function() {}.

      interface a {} и type a = {} соотносятся друг с другом так же.

      Я бы дал совет использовать interface для первичных объявлений, а type для производных типов.

      пример производного типа:

      type Partial<T> = {
          [P in keyof T]?: T[P];
      };


  1. FasSsko
    19.09.2025 08:45

    во первых большая часть советов никак не связана с TS, они в уелом про чистый код.

    во вторых enum (как и в целом все runtime TS фичи) это зло. Они нарушают саму идею TS как типизированный JS. Например последняя нода может игнорировать типы и запускать TS напрямую без транспайла, но только в том случае если не используются runtime фичи (enum, namespace, etc). Не говоря уже про возможные проблемы енамов с автогенерацией кода.

    в третьих лучше всегда использовать ‘interface’ over ‘type’. Использовать типы лучше только тогда, когда интерфейс не подходит, например union или intersection. Разница в том, что при пересечении типов TSу нужно больше памяти, чем при использовании интерфейсов с экстендом. На больших проектов это может влиять на скорость проверки типов тсом