Классы в JavaScript: синтаксис, наследование и приватные поля

Классы в JavaScript: синтаксис, наследование и приватные поля Полезное

Класс в JavaScript — это шаблон для создания объектов с общими данными и поведением, который объявляется ключевым словом class и используется через new. Под капотом класс остается функцией-конструктором, а методы лежат в прототипе: class (появился в ES2015) — удобный синтаксис над прототипным наследованием, а не отдельная модель, как в Java или C#.

Ниже — полный рабочий пример, затем по порядку: конструктор и поля, методы, геттеры и сеттеры, static, приватные члены #, наследование через extends и super, и типичные ошибки. Все примеры проверены в Node.js 24.21 (ветка LTS) 24.09.2026; в современных браузерах синтаксис тот же.

Три слова, которые легко спутать:

  • класс — сам шаблон (User);
  • экземпляр — конкретный объект, созданный из него (new User("Анна"));
  • прототип — объект User.prototype, где хранятся общие для всех экземпляров методы.

Аналогия: класс — чертеж дома, экземпляр — построенный по нему дом, прототип — общая для всех домов инструкция по эксплуатации.

Минимальный пример класса

class User {
  role = "student";          // публичное поле с умолчанием
  #password;                 // приватное поле

  constructor(name, password) {
    this.name = name;
    this.#password = password;
  }

  greet() {
    return `Привет, ${this.name} (${this.role})`;
  }

  checkPassword(value) {
    return this.#password === value;
  }
}

const anna = new User("Анна", "s3cret");
console.log(anna.greet());
console.log(anna.checkPassword("123"));
console.log(anna.checkPassword("s3cret"));
console.log(Object.keys(anna));

В консоли появится:

Привет, Анна (student)
false
true
[ 'role', 'name' ]

Что здесь происходит:

  1. new User(...) создает пустой объект, связывает его с User.prototype и вызывает constructor, где this — этот новый объект. Это упрощение для базового класса: у наследника объект создает конструктор родителя в момент вызова super(), поэтому до него this недоступен (см. ошибку 1 ниже).
  2. Поля, объявленные в теле класса (role, #password), инициализируются до выполнения тела конструктора (у наследника — сразу после super()) — поэтому в Object.keys сначала role, потом name.
  3. #password не попадает в Object.keys и не виден снаружи: прочитать его может только код внутри класса.
  4. Если конструктор не написать, JavaScript создаст пустой конструктор по умолчанию (у наследника — такой, что передает все аргументы в super).

Пароль здесь хранится открытым текстом только ради простоты примера: в реальном приложении пароли не хранят в объектах и не сравнивают напрямую, этим занимается сервер с хешированием.

Класс — это синтаксис над прототипами

Проверим утверждение из определения кодом:

class User {
  constructor(name) { this.name = name; }
  greet() { return `Привет, ${this.name}`; }
}
const u = new User("Анна");

console.log(typeof User);                                   // function
console.log(Object.getPrototypeOf(u) === User.prototype);   // true
console.log(Object.hasOwn(u, "greet"));                     // false
console.log(Object.hasOwn(User.prototype, "greet"));        // true

// Почти то же самое без class:
function OldUser(name) { this.name = name; }
OldUser.prototype.greet = function () { return `Привет, ${this.name}`; };
console.log(new OldUser("Иван").greet());

Вывод: function, true, false, true, Привет, Иван. Метод greet один на всех — он в прототипе, а не копируется в каждый объект.

«Почти то же самое» — не опечатка. У класса есть отличия от функции-конструктора:

Свойство class function + prototype
Вызов без new TypeError вызывается как обычная функция: this — глобальный объект или undefined в строгом режиме
Использование до объявления ReferenceError функция поднимается (hoisting)
Строгий режим внутри всегда только с "use strict"
Методы в for...in не перечисляются перечисляются, если добавлены присваиванием
Приватные поля #, static-блоки есть нет

Методы, геттеры и сеттеры

Метод экземпляра — функция в теле класса: greet() {...}. Внутри него this указывает на объект, у которого метод вызвали (anna.greet() — значит, anna).

Геттер и сеттер выглядят снаружи как обычное свойство, но при чтении и записи выполняют код. Типичная задача — проверить значение перед записью:

class Temperature {
  #celsius = 0;

  get celsius() { return this.#celsius; }

  set celsius(v) {
    if (typeof v !== "number" || Number.isNaN(v)) {
      throw new TypeError(`Ожидалось число, получено: ${v}`);
    }
    if (v < -273.15) {
      throw new RangeError("Ниже абсолютного нуля");
    }
    this.#celsius = v;
  }

  get fahrenheit() { return this.#celsius * 9 / 5 + 32; }
}

const t = new Temperature();
t.celsius = 25;
console.log(t.celsius, t.fahrenheit);   // 25 77
try {
  t.celsius = -300;
} catch (e) {
  console.log(e.name, e.message);
}

Вывод: 25 77 и RangeError Ниже абсолютного нуля. Значение хранится в приватном #celsius, а сеттер — единственный путь его изменить. У fahrenheit только геттер: это вычисляемое свойство «только для чтения».

static, приватные методы и static-блоки

Статические члены принадлежат самому классу, а не экземпляру: счетчики, константы, фабричные и служебные функции. Приватные члены с # доступны только внутри тела класса; это может быть поле, метод, геттер, в том числе статический.

class Counter {
  static created = 0;          // статическое поле: одно на класс
  static #secretPrefix = "id"; // статическое приватное поле
  static defaults;

  static {                     // статический блок: выполняется один раз при создании класса
    Counter.defaults = { step: 1, max: 3 };
  }

  #value = 0;

  constructor() {
    Counter.created += 1;
    this.id = `${Counter.#secretPrefix}-${Counter.created}`;
  }

  #clamp(n) {                  // приватный метод
    return Math.min(n, Counter.defaults.max);
  }

  inc() {
    this.#value = this.#clamp(this.#value + Counter.defaults.step);
    return this;
  }

  get value() { return this.#value; }

  static isCounter(obj) { return #value in obj; }
}

const c = new Counter();
c.inc().inc().inc().inc();
console.log(c.id, c.value);           // id-1 3
new Counter();
console.log(Counter.created);         // 2
console.log(c.created);               // undefined
console.log(Counter.isCounter(c), Counter.isCounter({ value: 3 }));

Вывод по строкам: id-1 3, 2, undefined, true false. Разбор:

  • Counter.created читается через имя класса; через экземпляр c.created — undefined.
  • Четыре вызова inc() упираются в max: 3 благодаря приватному методу #clamp. inc возвращает this, поэтому вызовы складываются в цепочку.
  • Статический блок static {...} удобен, когда инициализация статических полей требует нескольких шагов (например, заполнить справочник).
  • #value in obj — проверка «прошел ли объект через конструктор этого класса»: у обычного объекта с полем value приватного #value нет. Экземпляр наследника (class Sub extends Counter) эту проверку тоже проходит, так что это не строгое «ровно этот класс», а «этот класс или его потомок».

Приватность # обеспечивает сам язык, а не соглашение вроде подчеркивания _name: снаружи u["#password"] вернет undefined, а JSON.stringify приватные поля не выводит. Граница: это защита от случайного доступа из другого кода, а не средство хранения секретов — все, что попало в браузер, пользователь может увидеть в отладчике.

Наследование: extends и super

extends делает один класс наследником другого: экземпляр получает методы родителя через цепочку прототипов. super(...) вызывает конструктор родителя, super.method() — метод родителя.

class User {
  constructor(name) { this.name = name; }
  describe() { return `Пользователь ${this.name}`; }
}

class Admin extends User {
  constructor(name, level) {
    super(name);             // сначала конструктор родителя
    this.level = level;      // только после super() можно трогать this
  }
  describe() {
    return `${super.describe()}, уровень доступа ${this.level}`;
  }
}

const a = new Admin("Олег", 2);
console.log(a.describe());
console.log(a instanceof Admin, a instanceof User);
console.log(Object.getPrototypeOf(Admin.prototype) === User.prototype);

Вывод:

Пользователь Олег, уровень доступа 2
true true
true

Admin переопределил describe, но переиспользовал родительскую версию через super.describe(). Приватные поля родителя наследнику напрямую недоступны: #-имя видно только в теле того класса, где объявлено, поэтому наследник работает с ними через публичные методы или геттеры родителя.

Наследование удобно для настоящего отношения «является» (администратор является пользователем). Если связь другая — «содержит», «использует» — обычно проще передать объект в поле, чем строить глубокую иерархию.

Типичные ошибки с классами

1. Обращение к this до super().

class User {
  constructor(name) { this.name = name; }
}
class Admin extends User {
  constructor(name, level) {
    this.level = level;
    super(name);
  }
}
new Admin("Олег", 2);
ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

Исправление: в конструкторе наследника первой строкой, которая касается this, должен идти super(...), как в примере Admin выше.

2. Вызов класса без new.

class User {
  constructor(name) { this.name = name; }
}
const u = User("Анна");
TypeError: Class constructor User cannot be invoked without 'new'

Исправление: const u = new User("Анна");. Похожая ошибка — создать экземпляр до строки с class: объявление класса не поднимается, и Node.js выдаст ReferenceError: Cannot access 'User' before initialization.

3. Потеря this в колбэке. Метод, переданный как отдельная функция (в обработчик события, setTimeout, map), теряет связь с объектом:

class Button {
  constructor(label) { this.label = label; }
  handleClick() { return `Нажата кнопка ${this.label}`; }
}
const b = new Button("OK");
const handler = b.handleClick;   // метод оторван от объекта
console.log(handler());
TypeError: Cannot read properties of undefined (reading 'label')

Тело класса всегда в строгом режиме, поэтому this в оторванном методе — undefined, а не глобальный объект. Три способа исправить:

class Button {
  constructor(label) { this.label = label; }
  handleClick = () => `Нажата кнопка ${this.label}`; // поле-стрелка: this привязан к экземпляру
}
const b = new Button("OK");
const handler = b.handleClick;
console.log(handler());

class Button2 {
  constructor(label) { this.label = label; }
  handleClick() { return `Нажата кнопка ${this.label}`; }
}
const b2 = new Button2("Отмена");
console.log(b2.handleClick.bind(b2)());
setTimeout(() => console.log(b2.handleClick()), 0);

Вывод: Нажата кнопка OK, затем дважды Нажата кнопка Отмена. Как выбрать: поле-стрелка удобно для обработчиков, но создает отдельную функцию в каждом экземпляре; bind или стрелка-обертка в месте вызова оставляют метод в прототипе.

4. Приватное поле снаружи класса. Код console.log(u.#password) вне тела класса даже не запустится: Node.js останавливается на разборе файла с SyntaxError: Private field '#password' must be declared in an enclosing class. Нужен публичный метод или геттер.

5. Запись в свойство только с геттером. Если у свойства нет сеттера (c.value = 100 в примере Counter), результат зависит от режима файла: в обычном скрипте присваивание молча игнорируется, в ES-модуле (строгий режим) — TypeError: Cannot set property value of #<Counter> which has only a getter.

Выводы

  • Класс в JavaScript объявляется через class, экземпляр создается через new; под капотом это функция, а методы хранятся в прототипе.
  • Поля в теле класса инициализируются до кода конструктора; # дает приватность на уровне языка, static — члены самого класса, static {} — однократную инициализацию.
  • Наследник объявляется через extends, а в его конструкторе super(...) обязан идти до обращения к this.
  • Самые частые ошибки — вызов без new, обращение к this до super() и потеря this при передаче метода в колбэк.

Где применяется / связь с практикой

Классы встречаются во фронтенде и на Node.js: модели данных, сервисы работы с API, собственные ошибки (class NotFoundError extends Error), веб-компоненты (class MyWidget extends HTMLElement). Хорошее упражнение — переписать объект с функциями в класс с приватным состоянием и геттерами, а потом вынести общее поведение в родителя.

Освойте тему на практике

Если хочется разобрать классы, прототипы и this вместе с остальной базой языка на задачах с проверкой, посмотри курс Otus по JavaScript с нуля. Отдельные темы удобно попробовать на бесплатных открытых уроках Otus.

FAQ

Можно ли объявить класс выражением, без имени?
Да: const User = class { ... };. Такой класс ведет себя так же, его имя берется из переменной (User.name === "User").

Есть ли в JavaScript абстрактные классы и интерфейсы?
Ключевых слов для них в языке нет. Абстрактность обычно имитируют проверкой new.target === Base в конструкторе с выбросом ошибки, а интерфейсы описывают в TypeScript.

Чем приватное поле #name отличается от _name?
_name — только соглашение, свойство доступно всем и видно в Object.keys. #name недоступно вне тела класса на уровне синтаксиса.

OTUS Журнал
Бесплатные открытые уроки (поп-ап)