Как стать автором
Обновить
100.49
Cloud.ru
Провайдер облачных сервисов и AI-технологий

Рецепты TypeScript: простое тестирование типов

Уровень сложностиСредний
Время на прочтение5 мин
Количество просмотров2.3K

Привет, это снова Костя из Cloud.ru! В своих последних статьях я делился рецептами довольно сложных типов. Например, рассказывал, как преобразовывать ключи объектов из snake_case в camelCase. Давайте представим, что вы воспользовались одним из таких рецептов. Как его поддерживать? И что скажет ревьюер, увидев такой код?

Сегодня в меню не блюдо, а ложка — покажу, как можно надежно тестировать типы и не бояться их менять, если это нужно.

Так что же скажет ревьюер, глядя на код ниже? Скорее всего, он сделает очень глубокий выдох и отложит ваш реквест, либо не вчитываясь прожмет апрув. Более того, кто-то может посчитать, что ваш тип недостаточно хорош (и даже будет прав — в этом конкретном примере не обрабатываются кортежи), но, поправив его, сломает то, что работало. И ревью ему уже не поможет!

type KeysToCamelCase<T> = T extends Record<string, unknown>
  ? {
    [K in keyof T as CamelCase<K>]: KeysToCamelCase<T[K]>;
  }
  : T extends Array<infer U>
    ? Array<KeysToCamelCase<U>>
    : T;

Дисклеймер: вы можете покрыть комментариями буквально каждую строчку кода, и это безусловно поможет, но не решит одну из важных проблем — вы не сможете быть уверенными в работоспособности кода. Даже если оставить ссылку на подобную пятиминутную статью в качестве комментария, нет никакой гарантии, что ваш код работает так, как вы того хотите. Поэтому к теме с комментариями предлагаю больше не возвращаться, движемся дальше.

Подготавливаем тулы

Первым делом нам нужно научиться пробрасывать ошибки в TypeScript, причем желательно, чтобы это работало на уровне IDE (мы ведь достаточно ленивы, чтобы не запускать никаких отдельных команд, верно 😃?).

К счастью, мы уже знаем, что утилитарные типы можно запускать с ограничениями, и, если ограничения не проходят, то TypeScript сразу вернет нам ошибку, что нам и нужно. Поэтому напишем простую функцию, которая вернет нам ошибку, если переданный тип не true:

type Expect<T extends true> = T;

// Expect<true> - ✅
// Expect<false> - Type 'false' does not satisfy the constraint 'true'.

Всё, что нам остается — передавать туда некий утилитарный тип, который проверяет равенство переданных аргументов — его и напишем. Сложность этого в том, что у TypeScript'а нет оператора «‎==», вместо него мы должны использовать extends.

Можно попытаться пойти по ложному пути — предположить, что мы можем проверить выражения T1 extends T2 и T2 extends T1, и, если любой из результатов оказался ложным, вернуть false. Такое решение покроет большое количество кейсов, но не справится со всеми случаями, где первым аргументом при проверке окажется never.

Причина этого в механике работы тернарного оператора со словом never. Предполагается, что тип never расширяет все имеющиеся типы, поэтому в рамках логики TypeScript'а нет смысла считать выражение после never extends smth (баг это или фича — решайте сами):

type Extends<T1, T2> = T1 extends T2 ? true : false;
type Equals<T1, T2> = T1 extends T2 ? Extends<T2, T1> : false;

// Equals<never, unknown> === Equals<never, 'never'> === never
type NeverUnknown = Expect<Equals<never, unknown>>; // => Expect<never>
type NeverLiteral = Expect<Equals<never, 'never'>>, // => Expect<never>

Как мы можем убедиться, Expect<never> не вызовет ошибки, т. к. never расширяет любой тип, в том числе и true, а значит, это решение нам не подходит.

В этом случае поможет особенность расширения функций. В TypeScript'е это работает так: если одна функция расширяет другую, то вместо одного типа функции можно подставить другой тип без потери типа возвращаемого значения:

type Extends<T1, T2> = T1 extends T2 ? true : false;

type Fn1 = () => 'literal';
type Fn2 = () => string;

type TestCase1 = Extends<Fn1, Fn2> // true, 'literal' extends string
type TestCase2 = Extends<Fn2, Fn1> // false

Пока все просто — аргументы игнорируются, а результат предсказуем, но стоит добавить сюда дженерики, и все изменится:

type Extends<T1, T2> = T1 extends T2 ? true : false;

type Fn1 = <T>(x: T) => T extends 'literal' ? 2 : 1;
type Fn2 = <T>(x: T) => T extends string ? 2 : 1;

type TestCase1 = Extends<Fn1, Fn2> // false!
type TestCase2 = Extends<Fn2, Fn1> // false, как и ожидалось

Почему мы не получили true на седьмой строке? Дело в том, что когда в дело вмешиваются вычисляемые дженерики (в нашем случае это x: T), TypeScript вынужден просчитывать все возможные типы, которые может принять T. И если найдутся такие T, при которых результат вызова функций будет расходится (в примере выше это тип string), компилятор не даст нам расширить одну функцию другой. Буквально, мы получим true только в том случае, если часть после слова extends идентична, чего мы и добивались:

// Более лаконичная запись предыдущего кода с дженериками
// Вместо 2 и 1 можно использовать любые типы, не наследующие друг друга
// 2 и 1 использованы тут как каноничное решение, чтобы проще гуглилось =)
type Equals<T1, T2> = 
  (<T>(x: T) => T extends T1 ? 2 : 1) extends 
  (<T>(x: T) => T extends T2 ? 2 : 1) ? true : false

Тестируем

Всё, что нам остается — это создать файлик с тест-кейсами (или написать их рядом — это уже на ваш вкус), составив подборку разных способов применения. Вот пример тестов на Equals, который мы только что писали:

type Cases = [
  Expect<Equals<true, true>>,
  Expect<Equals<false, false>>,
  Expect<Equals<unknown, never>>, // Ошибка
  Expect<Equals<'literal', string>>, // Ошибка
  Expect<Equals<'1' | '2' | '2', '2' | '1'>>,
]

Иногда (как в нашем случае) мы хотим видеть тест-кейсы с ошибками, поэтому будет полезно маленькое дополнение:

type NotEquals<T1, T2> = true extends Equals<T1, T2> ? false : true;

// Теперь можно составлять и негативные тест-кейсы!
type Cases = [
  Expect<Equals<true, true>>,
  Expect<Equals<false, false>>,
  Expect<Equals<'1' | '2' | '2', '2' | '1'>>,
  Expect<NotEquals<unknown, never>>,
  Expect<NotEquals<'literal', string>>,
] // Компилируется без ошибок

Забираем готовый рецепт

Код с тестовым примером для тех, кто просто пришел за рецептом:

type Equals<T1, T2> = (<T>(x: T) => T extends T1 ? 0 : 1) extends (<T>(x: T) => T extends T2 ? 0 : 1) ? true : false
type NotEquals<T1, T2> = true extends Equals<T1, T2> ? false : true;

type Expect<T extends true> = T;

// Тесты, чтобы поэкспериментировать
type Cases = [
  Expect<Equals<true, true>>,
  Expect<Equals<never, never>>,
  Expect<Equals<'1' | '2' | '2', '2' | '1'>>,
  Expect<NotEquals<unknown, never>>,
  Expect<NotEquals<never, unknown>>,
  Expect<NotEquals<never, 'never'>>,
  Expect<NotEquals<any, unknown>>,
  Expect<NotEquals<number, any>>,
  Expect<NotEquals<number, unknown>>,
  Expect<NotEquals<number, string>>,
  Expect<NotEquals<[number], number[]>>,
  Expect<NotEquals<() => 'literal', () => string>>,
  Expect<NotEquals<'literal', string>>,
]

Спасибо, что дочитали до конца. Если хотите больше рецептов или разбор каких-то других смежных тем — в комментариях пишите, про что вам будет интересно почитать.

Другие статьи в блоге:

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+11
Комментарии6

Публикации

Информация

Сайт
cloud.ru
Дата регистрации
Дата основания
2019
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Елизавета