* Библиотекарь Тихой Академии говорит одно слово. Студенты первого года уверены, что другого он не знает. Студенты второго года знают, что в это слово он укладывает ровно столько, сколько нужно, и ни буквой меньше. Перевод с Ук всегда длиннее. И всегда хуже.
В конторе ты писал glyph: string и не задумывался. Компилятор с тобой соглашался: 'START', 'start' и 'наполовину' для него одинаково хороши. В прошлом уроке ты научился читать, что он говорит, когда не согласен. Теперь разберёмся, о чём он вообще судит.
Ответ Библиотеки короткий: тип это множество значений. Ставня это множество из двух положений, знак башни это тринадцать строк, string это все строки сразу. Компилятор проверяет две вещи: лежит ли значение в множестве и лежит ли одно множество внутри другого. На этой мысли стоит весь раздел, и к его концу ты прочитаешь через неё сигнатуру Effect<A, E, R>.
Полки Библиотеки
Библиотека Академии расставлена не по алфавиту. Каждая полка собирает книги по одному признаку: «о ставнях», «о Магистрали», «написано до основания города». Одна книга стоит на нескольких полках сразу, если подходит под несколько признаков. Полку, под признак которой не подошла ни одна книга, Библиотекарь всё равно не снимает, она так и стоит пустая. А вся Библиотека это полка «любая книга».
Типы TypeScript устроены так же. Значение это книга, тип это полка, то есть признак, по которому значение подходит или не подходит. number это полка всех чисел, string полка всех строк. А строка 'open', записанная на месте типа, это полка ровно с одной книгой:
const shutter: 'open' = 'open';
const word: string = shutter;
const back: 'open' = word;
literal.ts(3,7): error TS2322: Type 'string' is not assignable to type '"open"'.
Тип 'open' называется литеральный тип. Первая строка кладёт книгу 'open' на её собственную полку. Вторая ставит ту же книгу на полку всех строк: книга там есть, вопросов нет. Третья пытается вернуть книгу обратно, но про word компилятор теперь знает только полку string, а на ней лежат и 'closed', и 'Z'. Полка всех строк не помещается в полку из одной книги, отсюда ошибка.
Объединение: с любой из двух полок
Ставня бывает открыта или закрыта. Это две книги, и полку для них собирает знак |:
type Shutter = 'open' | 'closed';
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type Control = 'START' | 'STOP' | 'REPEAT';
type Glyph = Digit | Control;
const half: Shutter = 'half';
A | B это объединение: на полку A | B попадает всё, что стоит хотя бы на одной из двух. В Shutter два значения, в Digit десять, в Control три. Glyph объединяет цифры и служебные знаки, в нём тринадцать строк. Объединять можно и полки покрупнее: Glyph | number это тринадцать строк и все числа.
Перевод с Ук
Последняя строка примера пытается поставить ставню наполовину:
half.ts(6,7): error TS2322: Type '"half"' is not assignable to type 'Shutter'.
Ошибка в одну строку, читать снизу вверх нечего, но разобрать её по словам стоит: длинные ошибки собраны из тех же кусков. half.ts(6,7) это файл, строка 6, колонка 7, то есть имя константы. TS2322 это номер ошибки «не помещается», ты будешь встречать его чаще всех остальных. Type '"half"' это тип того, что ты пишешь: компилятор видит не «какую-то строку», а литерал "half", полку из одной книги. is not assignable to type 'Shutter' значит, что этой книги нет на полке Shutter. Компилятор не говорит «строка неправильная». Он говорит «такого значения в множестве нет»: на полке два значения, и он сверил с обоими.
Присваиваемость это вложенность
Цифра '7' стоит на полке Digit, на полке Glyph и на полке string. Значит, полка Digit целиком лежит внутри Glyph, а Glyph внутри string. Когда TypeScript решает, можно ли отдать A туда, где ждут B, он спрашивает ровно это: лежит ли множество A внутри множества B.
import type { Digit, Glyph } from './shifr';
const widen = (digit: Digit): Glyph => digit;
const narrow = (glyph: Glyph): Digit => glyph;
nesting.ts(4,41): error TS2322: Type 'Glyph' is not assignable to type 'Digit'.
Type '"START"' is not assignable to type 'Digit'.
Первая функция собирается: любая цифра это знак. Вторая нет, и её ошибку уже стоит читать снизу вверх. Нижняя строка называет конкретную книгу: "START" стоит на полке Glyph, но не на полке Digit. Верхняя строка делает из этого вывод про полки целиком. Компилятор нашёл контрпример, и одного ему хватает. Так он отвечает на вопрос о вложенности всегда: если не помещается, он показывает, что торчит наружу.
Это правило называют присваиваемостью, и из него собрана вся остальная проверка. Аргумент функции, возвращаемое значение, поле объекта, элемент массива: в каждом месте компилятор задаёт тот же вопрос про две полки.
Пересечение: сразу на двух полках
Знак & даёт пересечение: значения, которые стоят на обеих полках сразу. Какая строка одновременно цифра и служебный знак? Никакая. Пересечение Digit & Control пустое, и к этой пустой полке мы вернёмся ниже.
С объектами & поначалу сбивает с толку. Тип { readonly from: string } это полка всех объектов, у которых есть строковое поле from, а остальное неважно: подойдут и кадр, и письмо, и запись в журнале. Пересечение с полкой «есть поле to» оставляет только объекты, у которых есть оба поля:
type From = { readonly from: string };
type To = { readonly to: string };
const both: From & To = { from: 'T-1', to: 'T-2' };
const half: From & To = { from: 'T-1' };
const target = (frame: From | To) => frame.to;
objects.ts(5,7): error TS2322: Type '{ from: string; }' is not assignable to type 'From & To'.
Property 'to' is missing in type '{ from: string; }' but required in type 'To'.
objects.ts(6,44): error TS2339: Property 'to' does not exist on type 'From | To'.
Property 'to' does not exist on type 'From'.
Описание у From & To длиннее, а значений в нём меньше: каждое новое требование отсекает часть полки. С объединением наоборот. На полке From | To стоят объекты хотя бы с одним из двух полей, и про каждый известно меньше. Поэтому frame.to не собирается, а нижняя строка второй ошибки называет член объединения, у которого поля to может не быть. Больше значений значит меньше гарантий, меньше значений значит больше гарантий. Эта пара ещё вернётся, когда дойдём до вариантности.
Вся Библиотека и пустая полка
У системы типов две крайние полки.
unknown это вся Библиотека: на ней стоит любое значение, от '7' до функции. В unknown присваивается что угодно. Обратно не присваивается ничего: про книгу с полки «любая книга» неизвестно, строка это или число.
never это пустая полка. На неё нельзя поставить ни одно значение. Зато сама пустая полка помещается внутрь любой другой: в пустом множестве нет элемента, который торчал бы наружу.
import type { Control, Digit } from './shifr';
const start: Digit & Control = 'START';
const fail = (reason: string): never => {
throw new Error(reason);
};
const count: number = fail('ставня заела');
const wire: unknown = JSON.parse('"STOP"');
const glyph: string = wire;
edges.ts(3,7): error TS2322: Type '"START"' is not assignable to type 'never'.
edges.ts(9,7): error TS2322: Type 'unknown' is not assignable to type 'string'.
Первая ошибка показывает, что TypeScript сам свернул Digit & Control в never: пересечения в тексте уже нет, только пустая полка. Функция fail никогда не возвращается, поэтому её результат описан пустым типом, и седьмая строка собирается: значения нет, а пустое множество подходит под number. Последняя строка не собирается: в wire лежит «что-то», а «что-то» не помещается в полку строк. Как выбраться из unknown честно, без as, разберём в следующем уроке.
never ещё встретится. Через урок он станет проверкой, что ты разобрал все сообщения до одного, а в уроке про вариантность окажется, что задача с ошибкой типа never не падает никогда.
А any?
Вопрос, который задают все: где на этой картинке any? Нигде. any это не полка, а записка Библиотекарю «сюда не смотри». В any помещается всё, как в unknown, и сам any помещается куда угодно, как never. Ни одно множество так не умеет: быть больше всех и меньше всех сразу. Поэтому any не тип в смысле этого урока, а выключатель проверки. В задачах курса его нет, и в коде башни ему не место.
Разложи по полкам
Выбери значение, потом тип, куда его положить, и виджет скажет, входит ли оно туда и почему. Начни с '7': он попадает в Digit, а с ним в Glyph, string и unknown, потому что круги вложены и значение лежит во всех, что его окружают. 'half' остаётся только в string, 42 и null только в unknown. Круг never не примет ничего. Под диаграммой вопросы про & и |: сначала ответь сам, потом проверь, например, чему равны Digit | never и Glyph & string.
Под капотом
- Литерал помещается в литерал, только если они совпадают:
'7'в'7'да,'7'в'8'нет. - Литерал помещается в свою широкую полку:
'7'вstring,42вnumber. - Объединение слева помещается, если помещается каждый его член.
Glyphвstring: все тринадцать строк проверены по одной. - Объединение справа принимает значение, если подходит хотя бы один член.
'START'вGlyph: подошёлControl. - Объект помещается в объектный тип, если у него есть каждое требуемое поле подходящего типа. Лишние поля не мешают: на полке «есть поле
from» стоит и объект сto. Одно исключение: объект, написанный прямо на месте, проверяется строже, и лишнее поле в нём компилятор считает опечаткой.
Третье и четвёртое правила вместе объясняют ошибку из narrow: компилятор шёл по членам Glyph и на "START" нашёл тот, что не подошёл ни к одному члену Digit. А never можно считать объединением из нуля членов: '7' | never это '7'. Проверять в пустом объединении нечего, поэтому оно помещается куда угодно. Пустое множество выходит не особым случаем, а тем же третьим правилом.
Таблица на карточке
Осталось записать саму таблицу кодов. Ей нужен номер для каждого из тринадцати знаков, и ни одного лишнего:
import type { Glyph } from './shifr';
const CODE: Readonly<Record<Glyph, number>> = {
'0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
'5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
START: 60,
STOP: 61,
};
table.ts(3,7): error TS2741: Property 'REPEAT' is missing in type '{ '0': number; '1': number; '2': number; '3': number; '4': number; '5': number; '6': number; '7': number; '8': number; '9': number; START: number; STOP: number; }' but required in type 'Readonly<Record<Glyph, number>>'.
Record<Glyph, number> это объект, у которого есть ключ на каждый элемент множества Glyph, и под каждым ключом число. Ключей тринадцать, компилятор требует все, а забытый REPEAT называет по имени. Readonly запрещает переписать номер, когда таблица собрана: CODE.START = 1 упадёт с Cannot assign to 'START' because it is a read-only property. И раз множество ключей закрыто, CODE[glyph] для glyph: Glyph имеет тип number, и флаг noUncheckedIndexedAccess из прошлого урока не добавляет к нему | undefined: промахнуться мимо ключа нельзя.
Где тут дыра
const CODE: Record<string, number> = {
'0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
'5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
START: 60, STOP: 61, REPEAT: 62,
};
const code = (glyph: string): number => CODE[glyph] ?? 0;
console.log(code('START'));
console.log(code('start'));
60
0
На 'START' всё верно. Но ключи у Record<string, number> любые, поэтому под строгим флагом CODE[glyph] стал number | undefined, и Ринцвальд заткнул undefined нулём, чтобы компилятор отстал. Теперь смотритель соседней башни, уставший к концу смены, пишет 'start' маленькими буквами. Ключа нет, функция отдаёт 0, а 0 это код цифры '0': шесть закрытых ставен. Башня T-1 показывает ноль вместо начала кадра, никто не падает, отчёт зелёный.
Дыра не в ?? 0, а в полке. string слишком большое множество: на нём 'start' и 'START' для компилятора равны. Сузь полку до Glyph, и та же строка не соберётся:
import { CODE, type Glyph } from './shifr';
const code = (glyph: Glyph): number => CODE[glyph];
code('start');
fixed.ts(5,6): error TS2345: Argument of type '"start"' is not assignable to parameter of type 'Glyph'.
Ринцвальд не дурак, ему некогда. А башне некогда выяснять, какой из нулей настоящий.
Шифр, этап 1
Карточка Библиотекаря становится первым куском протокола shifr. Ты опишешь ставню, цифры, служебные знаки и сам знак как множества, соберёшь таблицу кодов и функцию, которая переводит знак в положение шести ставен. Тесты проверяют не только значения, но и типы: 'half' и 'Z' не должны собираться.
Практика
Глубже в архиве
Откуда растёт мысль «тип это множество значений, а проверка это вложенность», видно в курсе логики. Урок «Типизированная арифметика» вешает ярлык-тип на каждое выражение, а урок «Безопасность» доказывает, что программа с правильными ярлыками не застревает на полпути.
Дальше
Таблица кодов лежит на столе, Библиотекарь молча убирает карточку в ящик. В дверь протискивается Ринцвальд с листком в руке.
Кадр пришёл по проводу, а значит, для компилятора это unknown, вся Библиотека сразу. Как доказать ему, что перед тобой кадр, а не что угодно, разберём в следующем уроке.