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

Тесты на типы

middle-senior~30 мин

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

Вот тест, который ты написал к прошлому этапу:

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. Там же можно потренироваться: каждое домашнее задание проверяется такой таблицей.

Дальше

Карточки кончились. Библиотекарь собирает их со стола в одну стопку, выравнивает края и кладёт сверху чистую: завтра он принимает протокол целиком. Провод, конверты, разбор с ошибками, номера, которые не подделать, и тесты, которые проверяют всё это без тебя. Один раз, без подсказок. Приёмка в следующем уроке.