Ринцвальд прав в одном: компилятор будет доволен. Но то, что приходит снаружи, программа видит впервые: провод, файл, тело ответа. Формально JSON.parse возвращает any, а честный код сразу кладёт результат на полку unknown, потому что про байты с провода не известно ничего.
В прошлом уроке ты видел, что unknown не помещается ни в одну полку меньше себя. Этот урок о том, как переложить значение с полки «вся Библиотека» на полку Frame и при этом не соврать ни компилятору, ни башне.
as это обещание
import type { Frame } from './shifr';
const wire: unknown = JSON.parse('{"from":"T-7","to":"T-1"}');
const frame = wire as Frame;
console.log(frame.glyphs.length);
TypeError: Cannot read properties of undefined (reading 'length')
Компилятор не возразил ни словом. Приведение типа as не превращает значение в кадр и не смотрит внутрь. Это твоё обещание: «я знаю лучше, поверь». Компилятор поверил, а в кадре Ринцвальда нет поля glyphs, и программа упала строкой ниже, вдали от места, где соврали. В большой программе это «ниже» бывает в другом файле и через час.
Нужен обратный путь: проверить значение кодом, а компилятор пусть следит за проверками. Это сужение: после каждой проверки компилятор перекладывает значение на полку поменьше.
typeof: какой это примитив
const describe = (wire: unknown): string => {
if (typeof wire === 'string') return wire.toUpperCase();
if (typeof wire === 'number') return wire.toFixed(0);
return 'не знаю';
};
На входе вся Библиотека. Во второй строке, внутри ветки, wire уже string: компилятор видел проверку и знает, что сюда попадают только строки, поэтому разрешает toUpperCase. В третьей строке он number. После return в каждой ветке компилятор вычитает разобранное, и к последней строке wire это всё, кроме строк и чисел. Наведи курсор на wire в каждой строке, и подсказка покажет, как сужается полка.
typeof возвращает одну из восьми строк, и компилятор знает их все. Это объединение литералов из прошлого урока, поэтому опечатка не пройдёт:
typo.ts(1,35): error TS2367: This comparison appears to be unintentional because the types '"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"' and '"strin"' have no overlap.
Перевод с Ук
Кадр это объект, и первая мысль проверить его так же:
const glyphsOf = (wire: unknown) => {
if (typeof wire !== 'object') return undefined;
return wire.glyphs;
};
object.ts(3,10): error TS18047: 'wire' is possibly 'null'.
object.ts(3,15): error TS2339: Property 'glyphs' does not exist on type 'object'.
Две ошибки на одну строку, и читать их стоит по колонкам. Первая, в колонке 10, про сам wire: «возможно, это null». В JavaScript typeof null даёт 'object'. Это старая ошибка языка, её не исправили, чтобы не сломать чужой код. Проверка typeof пропустила null в ветку, и компилятор это помнит. Вторая, в колонке 15, про поле. На полке object стоят все объекты, массивы и функции, и ни про один не известно, есть ли у него glyphs. Тип object говорит «это не примитив» и ничего не говорит о полях. Первую ошибку чинит проверка wire === null, вторую оператор in.
in: есть ли поле
const sender = (wire: unknown): string | undefined => {
if (typeof wire !== 'object' || wire === null) return undefined;
if (!('from' in wire)) return undefined;
if (typeof wire.from !== 'string') return undefined;
return wire.from;
};
Каждая строка отрезает часть полки. После второй wire это object, уже без null. После третьей компилятор знает, что поле from есть: оператор in сужает тип до object & Record<"from", unknown>. Это пересечение из прошлого урока: объекты, у которых есть поле from, а в поле лежит что угодно. Поэтому нужна четвёртая строка, typeof уже для поля, и только после неё wire.from это строка. in отвечает на вопрос «есть ли поле», typeof на вопрос «что в нём», и одно без другого не работает.
instanceof: из какого класса
Провод отдаёт не только кадры. Когда линия обрывается, приходит Error:
import type { Frame } from './shifr';
const report = (received: Frame | Error): string => {
if (received instanceof Error) return `обрыв: ${received.message}`;
return `кадр от ${received.from}`;
};
instanceof проверяет цепочку прототипов, то есть класс, через new которого создан объект. В первой ветке received это Error, во второй остаётся только Frame: компилятор вычел Error из объединения. А вот сам кадр так проверить нельзя:
import type { Frame } from './shifr';
const isFrame = (received: unknown) => received instanceof Frame;
instance-bad.ts(3,60): error TS2693: 'Frame' only refers to a type, but is being used as a value here.
Frame это описание полки, а не класс. После сборки от типов не остаётся ничего, и в рантайме проверять не по чему. Сужение работает только через то, что существует при исполнении: typeof, in, instanceof, сравнения и Array.isArray.
С массивами есть ловушка. Array.isArray сужает unknown до any[], и any пролезает внутрь:
const wire: unknown = JSON.parse('["START", 7]');
if (Array.isArray(wire)) {
const first: number = wire[0];
}
Собирается без единого «Ук», хотя в first лежит строка 'START'. Элементы массива с провода придётся проверить каждый.
Предикат: проверка с именем
Цепочку проверок хочется вынести в функцию и звать её в десяти местах. Но обычная функция с boolean на выходе сужать не умеет:
import { CODE } from './shifr';
const isGlyph = (value: unknown) =>
typeof value === 'string' && Object.hasOwn(CODE, value);
const wire: unknown = 'STOP';
if (isGlyph(wire)) console.log(CODE[wire]);
nopred.ts(7,37): error TS2538: Type 'unknown' cannot be used as an index type.
Функция вернула true, но компилятор не знает, что это значит для wire. Ему надо сказать словами, и для этого есть предикат типа:
import { CODE, type Glyph } from './shifr';
const isGlyph = (value: unknown): value is Glyph =>
typeof value === 'string' && Object.hasOwn(CODE, value);
const wire: unknown = 'STOP';
if (isGlyph(wire)) console.log(CODE[wire]);
Запись value is Glyph на месте boolean значит: «если я вернула true, аргумент стоит на полке Glyph». Теперь CODE[wire] собирается. С TypeScript 5.5 компилятор иногда выводит предикат сам, когда проверка в теле однозначна, как у typeof value === 'string'. Для isGlyph не выводит: false здесь значит или «не строка», или «строка не из таблицы», и точно сказать про ложную ветку нечего.
Теперь неприятное: тело предиката компилятор с обещанием не сверяет.
import type { Frame } from './shifr';
const isFrame = (value: unknown): value is Frame => true;
const wire: unknown = 42;
if (isFrame(wire)) console.log(wire.from.length);
Собирается и падает на wire.from.length. Предикат это тот же as, только упакованный в функцию: компилятор верит подписи. Разница в том, что обещание дано в одном месте, рядом с проверкой, и это место можно покрыть тестами. Поэтому тесты этапа гоняют isGlyph и isFrame на плохих значениях, а не только на хороших.
asserts: проверь или брось
Иногда ветка не нужна: если кадр не по протоколу, работать дальше нечего. Для этого есть функция-утверждение:
import { type Frame, isFrame } from './shifr';
function assertFrame(value: unknown): asserts value is Frame {
if (!isFrame(value)) throw new TypeError('кадр не по протоколу');
}
const wire: unknown = JSON.parse('{"from":"T-7","to":"T-1","glyphs":["START"]}');
assertFrame(wire);
console.log(wire.glyphs);
Возвращаемый тип asserts value is Frame обещает: если функция вернулась, value это Frame. После вызова компилятор сужает wire до конца блока, без if. Если кадр плохой, функция бросает TypeError, и следующая строка не исполнится.
Тот же код, записанный стрелкой, не собирается:
import { type Frame, isFrame } from './shifr';
const assertFrame = (value: unknown): asserts value is Frame => {
if (!isFrame(value)) throw new TypeError('кадр не по протоколу');
};
const wire: unknown = JSON.parse('{}');
assertFrame(wire);
assert-arrow.ts(8,1): error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation.
Под капотом
Сужение держится на графе переходов: компилятор размечает, по каким веткам может пройти исполнение, и для каждой точки помнит, какие проверки уже позади. Утверждение меняет этот граф: после вызова assertFrame(wire) начинается участок, где wire уже кадр. Чтобы так разметить вызов, компилятор должен узнать, что функция утверждает, не выводя типы выражений: иначе разметка зависела бы от вывода, а вывод от разметки. Поэтому он смотрит только на объявленный тип имени. У объявления function тип записан прямо в сигнатуре. У константы со стрелкой тип выводится из выражения справа, а выводить здесь нельзя. Отсюда правило: утверждение объявляют через function или константой с явной аннотацией, const assertFrame: (value: unknown) => asserts value is Frame = ....
Где тут дыра
import { CODE, type Glyph, shutters } from './shifr';
const isGlyph = (value: unknown): value is Glyph =>
typeof value === 'string' && value in CODE;
console.log(isGlyph('7'), isGlyph('START'), isGlyph('Z'));
console.log(isGlyph('toString'), isGlyph('constructor'));
const wire: unknown = 'toString';
if (isGlyph(wire)) console.log(shutters(wire));
true true false
true true
[ 'closed', 'closed', 'closed', 'closed', 'closed', 'closed' ]
На цифрах и служебных знаках всё верно, 'Z' отсеян. Но in ищет ключ не только в самой таблице, а по всей цепочке прототипов. Таблица, как любой объект, унаследовала от Object.prototype методы toString, constructor, valueOf, hasOwnProperty и другие. Предикат пообещал компилятору Glyph, компилятор поверил, и shutters получил строку 'toString'. В таблице под этим ключом лежит функция, а не число; побитовый сдвиг превращает её в ноль, и башня показывает шесть закрытых ставен, то есть цифру '0'.
Object.hasOwn(CODE, value) смотрит только на собственные ключи объекта:
import { CODE } from './shifr';
console.log('toString' in CODE, Object.hasOwn(CODE, 'toString'));
console.log('START' in CODE, Object.hasOwn(CODE, 'START'));
true false
true true
В функции sender выше оператор in на своём месте: там он только разрешает прочитать поле, а что в поле, проверяет следующий typeof. Дыра открывается, когда in становится всей проверкой, а ключ приходит с провода.
Тот, кому нужно, чтобы башня T-1 показала ноль. По проводу Магистрали приходит что угодно и от кого угодно, а проверка, которая пропускает toString, пропустит и это.
Шифр, этап 2
Протокол учится принимать кадры с провода. Ты опишешь кадр, напишешь предикаты isGlyph и isFrame и утверждение assertFrame. После этапа башня T-1 верит только тому, что проверила сама: ни as, ни in вместо таблицы.
Практика
Дальше
Кадр Ринцвальда прошёл isFrame: отправитель, получатель, знаки из таблицы. Ты раскладываешь знаки по порядку и спотыкаешься. В одном кадре идут подряд текст, подтверждение чужого кадра и пинг, и по знакам не понять, где кончается одно и начинается другое. Как сделать, чтобы сообщение само говорило, что оно такое, разберём в следующем уроке.