Вот тест, который ты написал к прошлому этапу:
import { expect, it } from 'vitest';
import { towerId } from './ids';
it('towerId пропускает только код башни', () => {
expect(towerId('T-9')).toBe('T-9');
expect(towerId('мост')).toBeUndefined();
});
Пондер прав. Перед запуском тестов типы стираются, и в рантайме towerId возвращает обычную строку. С брендом или без, тест видит одно и то же. Всё, что ты строил в прошлом уроке, живёт только в типах, и проверять это тоже нужно в типах.
Для этого есть тесты на типы. Это файл, который компилятор проверяет так же строго, как код. Шаг Т в задачах шифра делает ровно это: собирает твой файл вместе со скрытыми тестами и только потом запускает их. В этом уроке ты узнаешь, как такие тесты написаны, почему они иногда врут и как написать свои Equal, Expect и IsNever, которым можно верить.
Присваивание проверяет половину
Первое, что приходит в голову, это присвоить результат переменной с нужным типом:
import { towerId, type TowerId } from './ids';
const checked: TowerId | undefined = towerId('T-9');
Собирается, и это что-то проверяет. Но вспомни урок 05: присваивание это вложенность, а не равенство. Строка проверяет, что результат помещается в TowerId | undefined. Если towerId по ошибке станет возвращать голый TowerId, без undefined, строка всё равно соберётся. И если вернёт never, тоже: пустая полка помещается куда угодно, как ты видел в уроке 13.
Это как весы на почте, которые проверяют только, что посылка не тяжелее нормы. Пустая коробка такую проверку проходит всегда.
expectTypeOf
В vitest есть expectTypeOf, он взят из пакета expect-type. Пишется он похоже на обычный expect, но проверяет типы:
import { expectTypeOf, it } from 'vitest';
import type { TowerCode } from './towers';
import { towerId, type TowerId } from './ids';
it('проверенный код не подделать', () => {
expectTypeOf(towerId).returns.toEqualTypeOf<TowerId | undefined>();
expectTypeOf<TowerId>().toExtend<TowerCode>();
expectTypeOf<TowerCode>().not.toExtend<TowerId>();
});
expectTypeOf(значение)берёт тип значения,expectTypeOf<Тип>()берёт тип напрямую..toEqualTypeOf<T>()требует равенства типов, а не вложенности.TowerIdбезundefinedздесь уже не пройдёт..toExtend<T>()требует вложенности, как присваивание..notпереворачивает проверку. Третья строка и есть тест «строку за башню не выдать»: обычный код башни не должен помещаться вTowerId..returnsпереходит к типу результата функции,.parameter(0)к типу первого параметра.
В рантайме эти вызовы ничего не делают. Вся работа происходит при сборке.
Теперь убери бренд, как боялся Библиотекарь: пусть в ids.ts тип TowerId станет обычным TowerCode. Тест краснеет.
Перевод с Ук
ids-types.test.ts(9,33): error TS2554: Expected 1 arguments, but got 0.
Одна строка, и та на первый взгляд о другом: «ожидался один аргумент, а передано ноль». Ты не передаёшь toExtend аргументов и не должен. Читать здесь нужно не текст, а место: девятая строка, тридцать третий столбец, это начало слова toExtend в проверке .not.toExtend<TowerId>(). Значит, не прошла эта проверка: TowerCode теперь помещается в TowerId.
Почему такой странный текст? Так устроен expect-type. Когда типы сошлись, у метода нет параметров. Когда не сошлись, у него возникает обязательный параметр особого типа, которого ты не передал, и компилятор ругается на число аргументов. Это стоит запомнить: в тестах на типы «Expected 1 arguments» значит «эта проверка не прошла», а подробности видны, если навести курсор на метод.
У toEqualTypeOf ошибка разговорчивее. Верни бренд, но пусть towerId возвращает string | undefined. Теперь краснеет седьмая строка, и библиотека успевает вписать расхождение в текст:
ids-types.test.ts(7,47): error TS2344: Type 'TowerId | undefined' does not satisfy the constraint '"Expected: undefined, Actual: string" | `Expected: literal string: ${TowerId}, Actual: string`'.
Type 'undefined' is not assignable to type '"Expected: undefined, Actual: string" | `Expected: literal string: ${TowerId}, Actual: string`'.
Здесь ответ прямо в кавычках: ожидалась строка-литерал вида TowerId, а пришла string.
Это не должно собираться
Иногда проверить нужно не тип, а целую строку кода: «вот так написать нельзя». Для этого есть комментарий @ts-expect-error:
import { it } from 'vitest';
import type { TowerId } from './ids-broken';
it('строку за проверенный код не выдать', () => {
// @ts-expect-error: строка не прошла проверку
const forged: TowerId = 'T-1';
void forged;
});
Он глушит ошибку на следующей строке, но если ошибки там нет, ругается сам. Здесь ids-broken.ts это модуль без бренда, и тест краснеет:
ids-expect-error.test.ts(6,3): error TS2578: Unused '@ts-expect-error' directive.
«Лишний @ts-expect-error»: ты обещал ошибку, а её нет. Так и задумано, это тест на «не собирается». У его старшего брата @ts-ignore такого нет: тот молчит, есть ошибка или нет, поэтому для тестов он бесполезен.
Два правила, которые соблюдают скрытые тесты шифра. Пиши после двоеточия, почему строка не должна собираться: через месяц это единственное, что объяснит тест. И если запрещённая строка бросила бы исключение в рантайме, положи её в функцию, которую никто не вызывает: const rejected = () => { ... }; void rejected;. Компилятор проверит тело, а запускать его никто не станет.
Свои Equal и Expect
expectTypeOf хорош внутри it. Но бывает, что проверить нужно целую таблицу типов, и хочется обойтись без библиотеки. Тогда тесты пишут прямо в типах. Первая половина такой проверки:
type Expect<T extends true> = T;
Expect принимает только true. Подставь в него false, и получишь ошибку Type 'false' does not satisfy the constraint 'true'. Нужна вторая половина: тип Equal<X, Y>, который даёт true, если типы равны, и false, если нет.
Первая мысль: равны, если каждый помещается в другой.
type NaiveEqual<X, Y> = [X] extends [Y] ? ([Y] extends [X] ? true : false) : false;
const anyIsString: NaiveEqual<any, string> = true;
const anyIsUnknown: NaiveEqual<any, unknown> = true;
const readonlyIsMutable: NaiveEqual<{ a: 1 }, { readonly a: 1 }> = true;
const sameUnion: NaiveEqual<1 | 2, 2 | 1> = true;
const different: NaiveEqual<string, 'a'> = false;
Квадратные скобки вокруг X и Y не украшение. Без них условный тип из урока 11 распределился бы по объединению и сравнивал бы члены 1 | 2 по одному.
Файл собирается, и это плохо: первые три строки врут.
anyпомещается во всё, и всё помещается вany. По взаимной вложенностиanyравен иstring, иunknown, и вообще чему угодно. Тест, который должен пойматьany, утёкший в публичный тип, его не поймает.readonlyу свойства на присваивание не влияет: TypeScript пускает объект сreadonly aтуда, гдеaможно менять. Взаимная вложенность различий не видит.
Тест на типы, который не отличает any от string, хуже, чем никакого: он зелёный. Нужно равенство строже, и оно есть:
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
const anyIsString: Equal<any, string> = false;
const anyIsUnknown: Equal<any, unknown> = false;
const readonlyIsMutable: Equal<{ a: 1 }, { readonly a: 1 }> = false;
const sameUnion: Equal<1 | 2, 2 | 1> = true;
const different: Equal<string, 'a'> = false;
Выглядит как заклинание, и его действительно передают из рук в руки, почти не читая. Мы прочтём.
Под капотом
Слева и справа стоят две функции без параметров, обе дженерики по T. Каждая возвращает условный тип T extends X ? 1 : 2. Чему равен T, неизвестно, поэтому компилятор не может решить условие и откладывает его: результат функции остаётся невычисленным условным типом.
Дальше внешний extends спрашивает, помещается ли левая функция в правую. Для этого компилятор сравнивает результаты, два отложенных условных типа. Для такого сравнения у него есть отдельное правило: два отложенных условных типа совместимы, только если типы после слова extends в них идентичны, а ветви совместимы. Идентичность строже взаимной вложенности. any идентичен только any. Свойство с readonly не идентично свойству без него. А вот порядок членов объединения не важен: 1 | 2 и 2 | 1 это один и тот же тип.
Вся конструкция сводит вопрос «равны ли X и Y» к вопросу «идентичны ли они», на который компилятор внутри умеет отвечать, а снаружи спросить нельзя. В expect-type тот же приём лежит под именем StrictEqualUsingTSInternalIdenticalToOperator: «строгое равенство через внутренний оператор идентичности TypeScript».
Это поведение компилятора, а не обещание из документации, и у него есть свои странности. Идентичность сравнивает типы буквально, поэтому Equal<{ a: 1 } & { b: 2 }, { a: 1; b: 2 }> даёт false, хотя значения у этих типов одни и те же. Если тест упал на пересечении, это может быть тот самый случай.
IsNever
Последний инструмент проверяет, что тип пуст. Наивная версия и честная рядом:
type NaiveIsNever<T> = T extends never ? true : false;
type IsNever<T> = [T] extends [never] ? true : false;
type Naive = NaiveIsNever<never>;
type Wrapped = IsNever<never>;
const naive: Naive = true;
const wrapped: Wrapped = true;
never.ts(7,7): error TS2322: Type 'true' is not assignable to type 'never'.
NaiveIsNever<never> дал не true и не false, а never. Причина та же, что со скобками в Equal. Условный тип над голым параметром распределяется по объединению: вычисляется для каждого члена отдельно, а результаты складываются. never это пустое объединение, членов у него ноль. Ноль вычислений, ноль результатов, и сумма ноля результатов это снова never. Проверка «пуст ли тип» на пустом типе не запускается вовсе.
Кортеж [T] это уже не голый параметр, распределения нет, и [never] extends [never] честно даёт true.
Где тут дыра
import { it } from 'vitest';
import type { TowerId } from './ids-broken';
it('строку за проверенный код не выдать', () => {
// @ts-expect-error
const forged: TowerID = 'T-1';
void forged;
});
Тест действительно ни разу не падал. Посмотри на тип: TowerID, а не TowerId. Такого имени нет, на строке ошибка «Cannot find name», и @ts-expect-error честно её глушит. Он не знает, какую ошибку ты ждал: подойдёт любая. Модуль ids-broken.ts без бренда, а тест зелёный. Зелёный, как отчёт Магистрали.
Как не попасть в ту же яму:
- где можно, бери
expectTypeOfвместо@ts-expect-error. Опечатка внутриexpectTypeOf<TowerCode>().not.toExtend<TowerID>()сама станет ошибкой, а не будет проглочена; - под
@ts-expect-errorдержи одну короткую строку и пиши причину. Чем меньше на строке, тем меньше там посторонних ошибок; - и главное: сломай код нарочно и посмотри, как тест краснеет. Убери бренд, запусти, верни бренд. Тест, который ты ни разу не видел красным, ничего не проверяет. Это ответ на вопрос Библиотекаря: проверяющего проверяет поломка.
Шифр, этап 10
Протокол получает собственные инструменты проверки: Equal, Expect и IsNever, без библиотек. Скрытые тесты этапа проверят их на тех самых случаях, где врёт наивная версия: any против unknown, readonly против изменяемого поля, пустое пересечение знаков. С ними завтрашнюю приёмку протокола можно будет записать в типах целиком.
Практика
Глубже в архиве
Тесты в стиле Expect<Equal<...>> как способ сдавать упражнения на типы использует урок «Программирование на типах · фундамент» из курса TypeScript. Там же можно потренироваться: каждое домашнее задание проверяется такой таблицей.
Дальше
Карточки кончились. Библиотекарь собирает их со стола в одну стопку, выравнивает края и кладёт сверху чистую: завтра он принимает протокол целиком. Провод, конверты, разбор с ошибками, номера, которые не подделать, и тесты, которые проверяют всё это без тебя. Один раз, без подсказок. Приёмка в следующем уроке.