Литеральные типы: когда «круг» — это тип
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В JavaScript "circle" — это значение. В TypeScript та же строка может стоять и в позиции типа: тогда она означает «здесь допустима ровно эта строка и никакая другая». Из таких типов собирают наборы вроде "circle" | "square" — и опечатка перестаёт доживать до запуска.
Аннотация : string разрешает любую строку — их бесконечно много. Литеральный тип сужает этот набор до одного элемента: до того самого значения, которое вы написали. Синтаксис прежний, необычно только место.
Компилятор возражает на присваивании: Тип ""square"" не может быть назначен для типа ""circle"". А в консоли всё равно square — знакомое по первой главе правило: диагностика не отменяет выполнение. До браузера доезжает обычный JavaScript, в котором никаких типов нет, и он честно делает то, что написано.
Кавычек в сообщении две пары не случайно: компилятор оборачивает имя типа в кавычки, а у строкового литерала кавычки уже свои. У числового литерала пара остаётся одна — «Тип "7"».
Переменная, которой можно присвоить ровно одно значение, сама по себе бесполезна. Но литеральные типы почти никогда не пишут руками поодиночке: чаще они появляются сами, при выводе типа — и вот тут начинается разница между let и const.
Само расширение вы уже видели в статье про вывод типов; здесь смотрим на него с другой стороны — что именно расширяется и во что. В следующем примере нет ни одной аннотации у переменных, типы компилятор выводит сам. Из одного и того же значения он выводит два разных типа.
Ошибка одна, на последней строке: Аргумент типа "string" нельзя назначить параметру типа ""circle"". Значение в обеих переменных одинаковое, а типы разные, потому что разные обещания. const запрещает переприсваивание — значит, в kind навсегда лежит именно "circle", и узкий тип ничему не помешает.
С let так нельзя. Объявляя let, вы как раз и говорите, что содержимое ячейки будет меняться; выведи компилятор тип "circle", любое присваивание в неё стало бы ошибкой. Поэтому при выводе типа для изменяемой ячейки он поднимается от литерала к его базовому типу — это и называется расширением: строка становится string, число — number, true — boolean.
В редакторе достаточно навести курсор на имя. В песочнице наведения нет, зато есть уже знакомый приём: присвоить значение заведомо чужому типу и прочитать ответ — в тексте ошибки компилятор сам называет тип, который вывел.
Две строки диагностики: Тип ""circle"" не может быть назначен для типа "null" и Тип "string" не может быть назначен для типа "null". Приём грубый, но в спорных местах отвечает на вопрос «что он там вывел» за пять секунд.
Расширение срабатывает в момент вывода, а не один раз при объявлении константы: стоит скопировать значение в let, и узкий тип потеряется. И наоборот — явная аннотация всегда сильнее вывода, в обе стороны.
| Объявление | Тип переменной | Почему так |
|---|---|---|
const kind = "circle" | "circle" | значение уже не сменить, узкий тип ничему не мешает |
let shape = "circle" | string | ячейку заведут ради изменений — тип расширяется |
let exact: "circle" = "circle" | "circle" | аннотация отменяет расширение |
const mode: string = "circle" | string | аннотация сильнее вывода и в обратную сторону |
Смысл появляется, когда значений несколько. Вертикальная черта перечисляет варианты, а type даёт получившемуся набору имя (про псевдонимы типов и их отличие от интерфейсов — в следующей главе).
Две ошибки — на "fatal" и на "warning": Аргумент типа ""warning"" нельзя назначить параметру типа "Level". Сравните с level: string: там оба вызова прошли бы молча, а неизвестный уровень всплыл бы в проде пустым бейджем или сломанным фильтром логов.
Выигрыш не только в проверке. Редактор подсказывает все допустимые значения по Ctrl+Space — набор перечислен прямо в типе. А если завтра уровней станет четыре, достаточно дописать вариант в одну строку, и компилятор сам покажет места, где новый случай не разобран.
Только не примите тип за проверку. Компилятор рассуждает о коде, который видит; данные из формы, из сети или из localStorage он не видел никогда. Литеральный тип — обещание автора, а не контроль на входе.
Здесь компилятор молчит: оператор as — это заявление «поверь, я знаю лучше» (подробный разбор — в статье «as и @ts-expect-error: когда вы врёте компилятору» из главы про границы программы). Запустите и посмотрите, чем такое доверие оборачивается.
Ни одной диагностики — и undefined в консоли. Тип сказал, что в shape лежит "circle" или "square", обращение по ключу выглядело безопасным, а в объекте такого ключа нет. Значения, приходящие извне, придётся проверять кодом; типы этим не занимаются.
Литеральный тип есть у любого примитива, а не только у строки. Числовые наборы читаются так же естественно.
Сообщение то же самое, только кавычек меньше: Тип "7" не может быть назначен для типа "Dice". И снова обратите внимание на консоль — семёрка спокойно доехала до выполнения.
А вот место, где интуиция подводит чаще всего: арифметика литеральные типы не сохраняет. Компилятор не считает значения — он складывает типы, и number + number даёт number.
Ошибка на четвёртой строке: Тип "number" не может быть назначен для типа "Dice". Формально компилятор прав дважды — не только потому, что не умеет вычислять, но и потому, что 6 + 1 действительно не входит в набор.
Булевых литеральных типов ровно два — true и false, а boolean и есть их объединение. Наборы можно смешивать: number | "auto" описывает величину, у которой кроме чисел есть одно особое слово.
Возвращаемый тип -1 | 0 | 1 у компаратора — не педантизм: он документирует договор сортировки лучше, чем number, и не даёт вернуть из функции разность.
Самая частая встреча с расширением выглядит так: значение объявлено через const, а компилятор всё равно говорит про string.
const держит саму переменную, а не то, что внутри объекта, — это правило из части I никуда не делось. Раз поле shape изменяемо, компилятор рассуждает о нём так же, как о let: выводит string. Отсюда и Аргумент типа "string" нельзя назначить параметру типа "Shape" на последней строке, и молча прошедшая «трапеция» на четвёртой.
Первое лекарство уже знакомо — аннотация. Она отменяет расширение и заодно ловит присваивание в поле.
Второе — сказать компилятору, что объект целиком неизменяемый. Суффикс as const делает все поля доступными только для чтения, а значит, расширять их незачем: типы остаются литеральными (подробно про as const — в статье «readonly и as const: неизменяемость в типах» из главы про объекты).
Диагностика теперь совсем другая: Не удается задать значение для "shape", так как это свойство, доступное только для чтения. Вызов draw компилятор пропустил без замечаний — тип поля стал "circle".
Правило на каждый день: тип расширяется там, где значение может измениться. Неизменяемое место — узкий тип, изменяемое — широкий.
- Литерал в позиции типа — это тип с единственным допустимым значением:
"circle",7,true. - Тип выводится из обещания, а не из значения:
constсохраняет литеральный тип,letрасширяет его доstring,number,boolean. - Явная аннотация сильнее вывода в обе стороны: она и удерживает узкий тип у
let, и расширяет уconst. - Объединение литералов через
|заменяетstringтам, где значений заранее известное количество, и ловит опечатки до запуска. - Поля объектов изменяемы, поэтому их типы расширяются даже у
const; помогает аннотация илиas const. - Проверка типов не отменяет выполнение: ошибочный код всё равно превращается в JavaScript и запускается.
1/4Рядом объявлены const kind = "circle" и let shape = "circle", аннотаций нет. Какие типы выведет компилятор?
2/4let roll: Dice = 3, где type Dice = 1 | 2 | 3 | 4 | 5 | 6. Строкой ниже roll = 7, ещё ниже console.log(roll). Что окажется в консоли?
3/4Есть type Theme = "light" | "dark" и const settings = { theme: "dark" }. Вызов applyTheme(settings.theme) компилятор не пропускает. В чём причина?
4/4Функция объявлена как log(level: Level, text: string), где Level — объединение литералов. Какое объявление переменной даст пройти вызову log(level, "готово")?
Четыре направления вместо любой строки
Опечатка в направлении сейчас доживает до запуска. Введите тип Direction так, чтобы правильный вызов остался правильным, а на "uo" компилятор ругался.
Набор допустимых значений перечисляем через вертикальную черту и даём ему имя.
Запустив, вы увидите и вторую половину урока: строка «иду uo» всё равно попадёт в консоль. В настоящем проекте такая диагностика валит проверку типов и не пускает изменения дальше, но сам по себе полученный JavaScript она не отменяет.
Переменная не подходит, а значение подходит
Значение "info" входит в Level, но вызов не проходит: Аргумент типа "string" нельзя назначить параметру типа "Level". Почините двумя разными способами, не меняя саму строку "info".
Причина — расширение: у let тип вывелся как string. Отсюда два лечения.
Первое: если переменную никто не переприсваивает, ей и не нужен let.
Второе: оставить let, но аннотацией отменить расширение — так переменная останется изменяемой в пределах набора.
Вариант let level: string = "info" не помогает: он закрепляет ровно тот широкий тип, из-за которого всё и началось.
Настройки в объекте
Тот же сюжет, но значение лежит в поле объекта, и const уже не помогает. Добейтесь, чтобы вызов applyTheme проходил — и заодно чтобы случайная запись в settings.theme стала ошибкой.
Оба требования сразу закрывает as const: поля становятся доступными только для чтения, а раз меняться им нельзя — расширять типы незачем.
Если поля должны оставаться изменяемыми, вызов чинит аннотация объекта — но запись settings.theme = "light" тогда останется законной, а ошибкой станет только значение вне набора.