Раздел 46 · Effect: Тихая Академия

Сужение

middle~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Ринцвальд прав в одном: компилятор будет доволен. Но то, что приходит снаружи, программа видит впервые: провод, файл, тело ответа. Формально 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: отправитель, получатель, знаки из таблицы. Ты раскладываешь знаки по порядку и спотыкаешься. В одном кадре идут подряд текст, подтверждение чужого кадра и пинг, и по знакам не понять, где кончается одно и начинается другое. Как сделать, чтобы сообщение само говорило, что оно такое, разберём в следующем уроке.