Опирается на правила: NODE-22NODE-24 из Node Style Guide → раздел 6. Иммутабельность.

Важно знать

  • readonly на полях — любое поле, которое не переприсваивается после конструктора, обязано быть readonly.
  • DI-зависимости NestJS — объявляются как private readonly прямо в параметрах конструктора; шаблонный код отсутствует.
  • as const — для литеральных констант и конфиг-объектов; без него TypeScript расширяет тип до string/number.
  • readonly T[] в публичных сигнатурах — когда метод возвращает коллекцию и мутация снаружи не предполагается.
  • Spread / structuredClone — единственный способ «изменить» объект; входные аргументы не мутируются никогда.
  • var запрещён (NODE-X10): const по умолчанию, let только когда значение реально переприсваивается.
  • Механику (const vs let, no-param-reassign) ловит ESLint; семантику (когда нужен readonly, когда spread) — ревью.
  • strictPropertyInitialization в tsconfig.json (strict: true) гарантирует, что readonly-поле инициализировано в конструкторе или объявлении.

Иммутабельность — дешевейший способ устранить целый класс ошибок: когда объект нельзя изменить после создания, его поведение предсказуемо в любой точке кода. TypeScript даёт для этого readonly и as const на уровне системы типов; runtime-защита — через spread и structuredClone. Три правила раздела закрывают три уровня: поля класса, литеральные значения, мутация аргументов.

readonly на полях класса

NODE-22: поле, которое не переприсваивается после конструктора, объявляется readonly.

class OrderProjection {
  readonly id: string;
  readonly customerId: string;
  readonly totalAmount: bigint;
  readonly createdAt: Date;

  constructor(id: string, customerId: string, totalAmount: bigint, createdAt: Date) {
    this.id = id;
    this.customerId = customerId;
    this.totalAmount = totalAmount;
    this.createdAt = createdAt;
  }
}

readonly фиксирует намерение: «этот объект описывает снимок состояния, его поля не изменятся». Любая попытка присвоить projection.id = '...' — ошибка компиляции, а не рантайм-сюрприз.

Для Record-образных VO TypeScript предоставляет утилитарный тип Readonly<T>:

type ProductSnapshot = Readonly<{
  id: string;
  name: string;
  priceKopecks: bigint;
  sku: string;
}>;

Readonly<T> делает все поля readonly без перечисления каждого вручную — удобно для объектов данных без методов.

DI-зависимости — private readonly в параметрах

NODE-22: зависимости NestJS объявляются через сокращённый синтаксис параметров конструктора.

@Injectable()
export class CreateOrderHandler {
  constructor(
    @Inject(ORDER_REPOSITORY)
    private readonly orderRepository: OrderRepository,
    @Inject(CUSTOMER_REPOSITORY)
    private readonly customerRepository: CustomerRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: CreateOrderCommand): Promise<OrderId> {
    const customer = await this.customerRepository.findById(command.customerId);
    const order = Order.create(customer, command.items);
    await this.orderRepository.save(order);
    await this.eventBus.publish(new OrderCreatedEvent(order.id));
    return order.id;
  }
}

private readonly в параметре конструктора — TypeScript-сокращение: TypeScript одновременно объявляет поле и присваивает его в конструкторе. Трёх строк вместо шести. readonly здесь не опция, а требование: зависимость не должна быть переприсвоена в течение жизни объекта.

Объявление поля отдельно от конструктора — антипаттерн:

@Injectable()
export class CreateOrderHandler {
  private orderRepository: OrderRepository;

  constructor(@Inject(ORDER_REPOSITORY) repo: OrderRepository) {
    this.orderRepository = repo;
  }
}

Это то же самое, только длиннее и без readonly. ESLint-правила @typescript-eslint/parameter-properties (опция prefer: ['private readonly']) и @typescript-eslint/prefer-readonly автоматизируют контроль.

as const для литеральных констант и конфиг-объектов

NODE-23: as const сужает тип литерала до точного значения.

Без as const TypeScript расширяет тип:

const direction = 'left';

Тип directionstring. TypeScript «предполагает», что переменная может быть переприсвоена другой строкой, поэтому тип широкий. С as const:

const direction = 'left' as const;

Тип — 'left'. Значение зафиксировано на уровне системы типов.

Для конфиг-объектов и наборов значений as const незаменим:

export const ORDER_STATUS = {
  NEW: 'new',
  CONFIRMED: 'confirmed',
  PAID: 'paid',
  CANCELLED: 'cancelled',
} as const;

export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];

OrderStatus'new' | 'confirmed' | 'paid' | 'cancelled'. Добавили новый статус в ORDER_STATUS — тип обновился автоматически. Никаких enum с непредсказуемым runtime-поведением (cross-ref NODE-X12).

Для конфигурации сервиса:

export const SBER_PAYMENT_CONFIG = {
  timeoutMs: 5000,
  maxRetries: 3,
  baseUrl: 'https://api.sber.ru/v1/payments',
} as const;

Без as const тип полей — number/string. С as const5000, 3, 'https://...'. Попытка переписать поле — ошибка компиляции.

readonly T[] в публичных сигнатурах

NODE-23: когда метод возвращает коллекцию и мутация снаружи не предполагается, тип аннотируется как readonly T[] или ReadonlyArray<T>.

interface OrderRepository {
  findByCustomerId(customerId: string): Promise<readonly Order[]>;
}

class OrderQueryService {
  async getCustomerOrders(customerId: string): Promise<readonly OrderSummary[]> {
    const orders = await this.orderRepository.findByCustomerId(customerId);
    return orders.map(OrderSummary.from);
  }
}

readonly Order[] не запрещает мутацию самих объектов Order — он запрещает push, pop, splice, прямое присвоение по индексу. Вызывающий код не может случайно сломать чужой кэш или коллекцию.

Разница типов:

const orders: readonly Order[] = [];
orders.push(order);            // ошибка компиляции
orders[0] = order;             // ошибка компиляции

const orders: Order[] = [];
orders.push(order);            // допустимо — нежелательный side-effect

Spread и structuredClone вместо мутации

NODE-24: входные аргументы функции не мутируются никогда. Для создания изменённой версии используются spread-оператор или structuredClone.

Мутация аргумента — одна из самых трудноуловимых ошибок: функция с виду не возвращает ничего нового, но изменяет объект, которым владеет вызывающий код.

function applyDiscount(order: Order, discountPercent: number): Order {
  return {
    ...order,
    totalAmount: order.totalAmount - (order.totalAmount * BigInt(discountPercent)) / 100n,
  };
}

Spread создаёт новый объект с изменённым полем; исходный order остаётся нетронутым. Вызывающий код хранит оригинал и получает новый объект — оба валидны.

Для глубокого клонирования — structuredClone:

function enrichProduct(product: Product, attributes: Record<string, string>): Product {
  const clone = structuredClone(product);
  Object.assign(clone.attributes, attributes);
  return clone;
}

structuredClone — стандарт Web API, доступен в Node.js начиная с v17. Клонирует глубоко, включая Date, Map, Set, типизированные массивы. Не клонирует функции и прототипы — только данные. Для объектов с методами (классовые инстанции) — spread достаточен.

Для иммутабельного обновления вложенных объектов:

function updateShippingAddress(
  order: Order,
  newAddress: Address,
): Order {
  return {
    ...order,
    shipping: {
      ...order.shipping,
      address: newAddress,
    },
  };
}

Каждый уровень вложенности — отдельный spread. Глубже двух уровней — сигнал, что структура данных требует пересмотра.

Что запрещено

АнтипаттернПравилоЧто взамен
Поле без readonly при отсутствии переприсваиванияNODE-22readonly id: string
private service: SomeService в конструкторе без readonlyNODE-22private readonly service: SomeService
const config = { timeout: 5000 } без as constNODE-23{ timeout: 5000 } as const
Order[] в возвращаемом типе публичного метода, если мутация не нужнаNODE-23readonly Order[]
argument.field = newValue внутри функцииNODE-24return { ...argument, field: newValue }
argument.items.push(item)NODE-24return { ...argument, items: [...argument.items, item] }
var count = 0NODE-X10let count = 0 (или const если не переприсваивается)

Куда дальше

  • node/naming.md — UPPER_SNAKE_CASE для констант уровня модуля и DI-токенов.
  • node/imports.md — named exports и path-aliases; как модуль связан с тем, что он экспортирует.
  • node/expressions.md — unknown + narrowing, guard clause, явные возвращаемые типы.
  • node/async.md — async/await, Promise.all, запрет fire-and-forget.
  • node/tooling.md — tsconfig.json strict, ESLint strictTypeChecked, CI-прогон.
  • Раздел «Стандарты → Code Style» — хаб языковых биндингов: Java, Node, Python, Go.