Класс как тип и как значение

классы≈ 9 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Класс из части I никуда не делся: под ним всё та же функция-конструктор с прототипом. Но в TypeScript одно объявление class заводит сразу две сущности с одинаковым именем — значение, которое передают и вызывают через new, и тип, которым описывают экземпляр. Почти все недоумения вокруг классов растут из того, что эти двое живут под одним именем.

Разделы темы:·

Начнём со строки, которая выглядит тавтологией, а на деле содержит два разных Point.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

После двоеточия имя Point — это тип: «объект с полями x и y, каким его делает этот класс». После new то же имя — значение: обычная функция, что typeof и подтверждает. Компилятор держит два независимых пространства имён, и объявление класса занимает имя сразу в обоих.

Пока каждое имя стоит на своей стороне, разница незаметна. Она вылезает, когда стороны путают. Что скажет компилятор здесь и что попадёт в консоль?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2739: «В типе "typeof Point" отсутствуют следующие свойства из типа "Point": x, y». Главное в этой фразе — имя typeof Point: так компилятор называет тип самого класса. Полей у него правда нет, они появляются только после new. И заметьте, что консоль всё равно напечатала undefined: диагностика выполнение не отменяет.

Подсказка на первое время: слева от двоеточия читается значение, справа — тип. Имя класса означает разное в зависимости от того, с какой стороны оно стоит.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

cover? — необязательное поле: до присвоения там undefined, и тип у него string | undefined. slug объявлен без значения, но присвоен в конструкторе — этого компилятору достаточно; что будет, если забыть и то и другое, разбирает следующая статья главы.

Теперь нарушим два обещания сразу. Сколько будет диагностик — и что напечатает вторая строка вывода?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2322 «Тип "string" не может быть назначен для типа "number"» и TS2540 «Не удается задать значение для "host", так как это свойство, доступное только для чтения». А дальше самое поучительное: оба присваивания всё равно прошли. readonly — обещание компилятору, а не замок в объекте; в port легла строка, и + 1 честно склеил её в "4431". Ровно та ошибка, которую типы и должны были предотвратить, — если бы вы их послушали.

Имя класса в позиции типа не означает «объект, созданный именно этим классом». Оно означает форму: набор полей и методов. Всё, что по форме подходит, компилятор пропустит.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Литерал прошёл в функцию, которая просит Point, и посчитал пятёрку. А instanceof тут же ответил false: по форме объект подходит, по происхождению — нет. Это та же структурная типизация, что и у обычных объектов из третьей главы, просто теперь форму описывает класс.

Следствие бывает неприятным: два несвязанных класса с одинаковыми полями взаимозаменяемы.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни наследования, ни общего интерфейса — и ни одной диагностики. Если такое смешение опасно, формы нужно развести; как это делают приватным полем, разбирает статья про implements и приватность.

Совпадать должны все члены, а не только данные. Метод в классе сразу поднимает планку.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2741: «Свойство "tick" отсутствует в типе "{ start: number; }" и является обязательным в типе "Timer"». Диагностика называет обе формы целиком — и ту, что дали, и ту, что ждали. А выполнение, как всегда, идёт дальше и падает настоящим TypeError.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две диагностики, и обе по делу. TS2351 «Это выражение не может быть построено. Тип "Dog" не содержит сигнатуры конструкции» — экземпляр нельзя вызвать через new. TS2345 заходит с другой стороны: «Аргумент типа "typeof Dog" нельзя назначить параметру типа "Dog"». Компилятор сам произнёс нужное слово — остаётся вписать его в аннотацию.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

typeof Dog в позиции типа — это «тип самого класса»: конструктор с такой сигнатурой плюс все статические члены. С typeof из части I у него общее только написание: тот работает в рантайме и возвращает строку, этот вычисляется компилятором и до браузера не доезжает. Что имеется в виду, определяет позиция — снова та же развилка «значение или тип».

ЗаписьЧто означаетЧто подойдёт
Dog после двоеточияэкземпляр классаnew Dog("Рекс"), любой объект такой формы
Dog в позиции значениясам класс, функция-конструктортолько само имя
typeof Dogтип класса: конструктор и статикаDog, но не экземпляр
new (name: string) => Dogлюбой конструктор с такой сигнатуройDog и любой другой подходящий класс

Разделение на две стороны видно и на статических членах: они принадлежат классу, а не экземпляру, — значит, в типе экземпляра их нет.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2576 не просто ругается, а подсказывает: «Свойство "zero" отсутствует в типе "Temperature". Вы хотели обратиться к статическому элементу "Temperature.zero"?». Статика живёт в typeof Temperature, поля и методы — в Temperature. И снова консоль договаривает: undefined, потому что в рантайме у экземпляра этого свойства действительно нет.

typeof Dog требует именно этот класс. Если подойдёт любой конструктор нужной формы, тип пишут напрямую — со словом new внутри.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Такая запись называется сигнатурой конструирования — той самой, которой, по словам TS2351, не хватало типу экземпляра.

Всё, что мы расставляли, до браузера не доедет: аннотации, readonly, вопросительный знак у поля — разметка для компилятора. После сборки от класса остаётся ровно та функция-конструктор, которую вы знаете из части I. Отсюда самая коварная ошибка главы — и здесь компилятор промолчит.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и TypeError: ghost.speak is not a function на исполнении. as не преобразует объект, а обещает компилятору, что тип уже такой; проверять он после этого не станет. Пустой объект получил тип Dog, но не получил ни поля, ни метода — прототипа-то нет. Подробный разбор as и его законных применений ждёт в статье «as и @ts-expect-error: когда вы врёте компилятору» из главы про границы программы.

Обратная сторона у этого хорошая. Класс — редкая конструкция, которая даёт и тип, и проверку в рантайме сразу: interface после компиляции исчезает бесследно, а класс остаётся значением, и про него можно спросить.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Класс можно записать выражением и положить в переменную — он ведь значение. Но тогда имени-типа не появляется, и попытка использовать переменную как тип даёт отдельную диагностику.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2749: «"Counter" относится к значению, но здесь используется как тип. Возможно, вы имели в виду "typeof Counter"?». Подсказку тут стоит читать с осторожностью: typeof Counter — это тип класса, а нам нужен тип экземпляра. Достать его из выражения класса помогают утилиты из девятой главы; проще же объявить класс обычным class Counter { … } и получить оба имени бесплатно.

  • Одно объявление class заводит два имени: значение — функцию-конструктор — и тип экземпляра.
  • Имя класса после двоеточия означает экземпляр; тип самого класса записывается как typeof Класс.
  • Статические члены живут в typeof Класс, поля и методы — в типе экземпляра; на путанице ловит TS2576.
  • Тип экземпляра структурен: подходящий по форме литерал компилятор пропустит, хотя instanceof ответит false.
  • Аннотации, readonly и необязательность стираются при компиляции — от класса остаётся обычный JavaScript.
  • as не создаёт экземпляр, а только обещает тип: компилятор промолчит, а TypeError прилетит на исполнении.

1/4Класс Point объявлен с полями x и y. Что произойдёт со строкой const wrong: Point = Point;?

2/4Функция принимает параметр типа Point — это класс с полями x и y и без методов. В неё передали литерал { x: 3, y: 4 }. Что будет?

3/4В функцию нужно передать сам класс Dog, чтобы внутри вызвать new. Как объявить параметр?

4/4У класса Temperature есть статическое поле zero. Что даст обращение t.zero у экземпляра?

Расставить типы в классе

Класс написан на чистом JavaScript и работает, но компилятор выдаёт четыре диагностики TS7006. Проставьте типы так, чтобы диагностик не осталось, а поведение не изменилось. Подсказка: параметры колбэка в reduce трогать не придётся — они выведутся сами, как только станет известен тип массива.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Хватает трёх аннотаций: поле, параметр конструктора и параметр метода. Тип возврата у total компилятор выведет сам, но написать его полезно — геттер тогда не сможет случайно начать возвращать что-то другое.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Две диагностики про sum и n исчезли сами: как только items стал number[], типы параметров колбэка вывелись из сигнатуры reduce.

Фабрика, которая принимает класс

Функция должна создать несколько роботов, получив класс аргументом. Код работает, но компилятор недоволен: TS2351 на new Factory(i) и TS2345 на вызове. Исправьте одну аннотацию.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Параметр Factory — это сам класс, а не экземпляр, поэтому его тип пишется как typeof Robot. Всё остальное остаётся: возвращает функция именно экземпляры, и там Robot[] уже верно.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если бы функция должна была принимать не только Robot, но и любой класс с конструктором (id: number), вместо typeof Robot подошла бы сигнатура конструирования new (id: number) => Robot.

Убрать as

Компилятор молчит, а код падает с TypeError. Найдите причину и почините — не заглушая проблему, а получив настоящий экземпляр. Аннотации в классе менять не нужно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

as ничего не создаёт: он лишь велит компилятору считать литерал сессией. Метода greet в объекте нет и не появится — он живёт в прототипе, а прототип даёт только new.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Общее правило: данные извне — это данные, а не экземпляры. Класс из них собирают конструктором, и заодно появляется место, куда положить проверку входа.