Класс как тип и как значение
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Класс из части I никуда не делся: под ним всё та же функция-конструктор с прототипом. Но в TypeScript одно объявление class заводит сразу две сущности с одинаковым именем — значение, которое передают и вызывают через new, и тип, которым описывают экземпляр. Почти все недоумения вокруг классов растут из того, что эти двое живут под одним именем.
Начнём со строки, которая выглядит тавтологией, а на деле содержит два разных Point.
После двоеточия имя Point — это тип: «объект с полями x и y, каким его делает этот класс». После new то же имя — значение: обычная функция, что typeof и подтверждает. Компилятор держит два независимых пространства имён, и объявление класса занимает имя сразу в обоих.
Пока каждое имя стоит на своей стороне, разница незаметна. Она вылезает, когда стороны путают. Что скажет компилятор здесь и что попадёт в консоль?
TS2739: «В типе "typeof Point" отсутствуют следующие свойства из типа "Point": x, y». Главное в этой фразе — имя typeof Point: так компилятор называет тип самого класса. Полей у него правда нет, они появляются только после new. И заметьте, что консоль всё равно напечатала undefined: диагностика выполнение не отменяет.
Подсказка на первое время: слева от двоеточия читается значение, справа — тип. Имя класса означает разное в зависимости от того, с какой стороны оно стоит.
Типы внутри класса ставят в трёх местах: у полей, у параметров конструктора и методов, у возвращаемых значений. Поле со значением по умолчанию аннотировать не обязательно — работает тот же вывод типов, что и у обычной переменной.
cover? — необязательное поле: до присвоения там undefined, и тип у него string | undefined. slug объявлен без значения, но присвоен в конструкторе — этого компилятору достаточно; что будет, если забыть и то и другое, разбирает следующая статья главы.
Теперь нарушим два обещания сразу. Сколько будет диагностик — и что напечатает вторая строка вывода?
TS2322 «Тип "string" не может быть назначен для типа "number"» и TS2540 «Не удается задать значение для "host", так как это свойство, доступное только для чтения». А дальше самое поучительное: оба присваивания всё равно прошли. readonly — обещание компилятору, а не замок в объекте; в port легла строка, и + 1 честно склеил её в "4431". Ровно та ошибка, которую типы и должны были предотвратить, — если бы вы их послушали.
Имя класса в позиции типа не означает «объект, созданный именно этим классом». Оно означает форму: набор полей и методов. Всё, что по форме подходит, компилятор пропустит.
Литерал прошёл в функцию, которая просит Point, и посчитал пятёрку. А instanceof тут же ответил false: по форме объект подходит, по происхождению — нет. Это та же структурная типизация, что и у обычных объектов из третьей главы, просто теперь форму описывает класс.
Следствие бывает неприятным: два несвязанных класса с одинаковыми полями взаимозаменяемы.
Ни наследования, ни общего интерфейса — и ни одной диагностики. Если такое смешение опасно, формы нужно развести; как это делают приватным полем, разбирает статья про implements и приватность.
Совпадать должны все члены, а не только данные. Метод в классе сразу поднимает планку.
TS2741: «Свойство "tick" отсутствует в типе "{ start: number; }" и является обязательным в типе "Timer"». Диагностика называет обе формы целиком — и ту, что дали, и ту, что ждали. А выполнение, как всегда, идёт дальше и падает настоящим TypeError.
Иногда в функцию нужно передать не экземпляр, а сам класс — чтобы она сама вызвала new. Первая попытка почти всегда выглядит так.
Две диагностики, и обе по делу. TS2351 «Это выражение не может быть построено. Тип "Dog" не содержит сигнатуры конструкции» — экземпляр нельзя вызвать через new. TS2345 заходит с другой стороны: «Аргумент типа "typeof Dog" нельзя назначить параметру типа "Dog"». Компилятор сам произнёс нужное слово — остаётся вписать его в аннотацию.
typeof Dog в позиции типа — это «тип самого класса»: конструктор с такой сигнатурой плюс все статические члены. С typeof из части I у него общее только написание: тот работает в рантайме и возвращает строку, этот вычисляется компилятором и до браузера не доезжает. Что имеется в виду, определяет позиция — снова та же развилка «значение или тип».
| Запись | Что означает | Что подойдёт |
|---|---|---|
Dog после двоеточия | экземпляр класса | new Dog("Рекс"), любой объект такой формы |
Dog в позиции значения | сам класс, функция-конструктор | только само имя |
typeof Dog | тип класса: конструктор и статика | Dog, но не экземпляр |
new (name: string) => Dog | любой конструктор с такой сигнатурой | Dog и любой другой подходящий класс |
Разделение на две стороны видно и на статических членах: они принадлежат классу, а не экземпляру, — значит, в типе экземпляра их нет.
TS2576 не просто ругается, а подсказывает: «Свойство "zero" отсутствует в типе "Temperature". Вы хотели обратиться к статическому элементу "Temperature.zero"?». Статика живёт в typeof Temperature, поля и методы — в Temperature. И снова консоль договаривает: undefined, потому что в рантайме у экземпляра этого свойства действительно нет.
typeof Dog требует именно этот класс. Если подойдёт любой конструктор нужной формы, тип пишут напрямую — со словом new внутри.
Такая запись называется сигнатурой конструирования — той самой, которой, по словам TS2351, не хватало типу экземпляра.
Всё, что мы расставляли, до браузера не доедет: аннотации, readonly, вопросительный знак у поля — разметка для компилятора. После сборки от класса остаётся ровно та функция-конструктор, которую вы знаете из части I. Отсюда самая коварная ошибка главы — и здесь компилятор промолчит.
Ни одной диагностики — и TypeError: ghost.speak is not a function на исполнении. as не преобразует объект, а обещает компилятору, что тип уже такой; проверять он после этого не станет. Пустой объект получил тип Dog, но не получил ни поля, ни метода — прототипа-то нет. Подробный разбор as и его законных применений ждёт в статье «as и @ts-expect-error: когда вы врёте компилятору» из главы про границы программы.
Обратная сторона у этого хорошая. Класс — редкая конструкция, которая даёт и тип, и проверку в рантайме сразу: interface после компиляции исчезает бесследно, а класс остаётся значением, и про него можно спросить.
Класс можно записать выражением и положить в переменную — он ведь значение. Но тогда имени-типа не появляется, и попытка использовать переменную как тип даёт отдельную диагностику.
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 трогать не придётся — они выведутся сами, как только станет известен тип массива.
Хватает трёх аннотаций: поле, параметр конструктора и параметр метода. Тип возврата у total компилятор выведет сам, но написать его полезно — геттер тогда не сможет случайно начать возвращать что-то другое.
Две диагностики про sum и n исчезли сами: как только items стал number[], типы параметров колбэка вывелись из сигнатуры reduce.
Фабрика, которая принимает класс
Функция должна создать несколько роботов, получив класс аргументом. Код работает, но компилятор недоволен: TS2351 на new Factory(i) и TS2345 на вызове. Исправьте одну аннотацию.
Параметр Factory — это сам класс, а не экземпляр, поэтому его тип пишется как typeof Robot. Всё остальное остаётся: возвращает функция именно экземпляры, и там Robot[] уже верно.
Если бы функция должна была принимать не только Robot, но и любой класс с конструктором (id: number), вместо typeof Robot подошла бы сигнатура конструирования new (id: number) => Robot.
Убрать as
Компилятор молчит, а код падает с TypeError. Найдите причину и почините — не заглушая проблему, а получив настоящий экземпляр. Аннотации в классе менять не нужно.
as ничего не создаёт: он лишь велит компилятору считать литерал сессией. Метода greet в объекте нет и не появится — он живёт в прототипе, а прототип даёт только new.
Общее правило: данные извне — это данные, а не экземпляры. Класс из них собирают конструктором, и заодно появляется место, куда положить проверку входа.