Привет, это снова Костя из 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>>,
]

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

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

Комментарии (6)


  1. undersunich
    17.12.2024 13:05

    Если со стороны здравого смысла на все это посмотреть то это просто жуть. Зачем все эти усложнения?Для кого?Похоже все это нужно только "тайпскрипту"


    1. Konstantin_Loginovskikh Автор
      17.12.2024 13:05

      Почему же?
      Вот написали вы тип по типу такого

      type ExtractPath<T extends string> = T extends `:${infer Param}` ? Param : never;
      
      type ExtractPaths<T extends string> =
        T extends `${infer Left}/${infer Right}`
          ? ExtractPaths<Left> | ExtractPaths<Right>
          : ExtractPath<T>;
      
      type PathParams<T extends string> = Prettify<Record<ExtractPaths<T>, string>>;
      

      Как вам убедиться, что все это работает и потом поддерживать? В обычном js мы спокойно берем условный jest и покрываем решение тестами, но тестов на типы в ts просто нет
      В этот момент вы используете описанные в статье шаги и пишете тест-кейсы, например, такие

      type Cases = [
        Expect<Equals<ExtractPath<':user'>, 'user'>>,
        Expect<Equals<ExtractPath<'user'>, never>>
      ]
      

      И достигаете сразу двойную пользу - ревьюер(пользователь в лице другого разраба) сразу видит, как работает этот код, а при его изменениях ts защитит вас от неожиданного поведения и поломок

      Вся эта кухня нужна ровно с той же целью, как и автотесты, только для сложных типов, которые с первого раза иногда прочитать то сложно, не то чтобы проанализировать


      1. undersunich
        17.12.2024 13:05

        Со стороны здравого смысла "ExtractPath" это действие/команд а "Тип" это над множество. Типы надо вводит по предметной области и если смешивать понятийную основу то получается взрыв мозга здравого смысла и как следствие не читаемый код


  1. Alexandroppolus
    17.12.2024 13:05

    в рамках логики TypeScript'а нет смысла считать выражение после never extends smth (баг это или фича — решайте сами)

    Это фича :) На самом деле тут дистрибуция тернарников. Тип never в данном случае рассматривается не как "один элемент", а как "объединение нуля элементов", ну и соответственно ничего ни с чем не сравнивается. Простой и интуитивно понятный Equals можно было бы написать так:

    type EqualsV2<A, B> = [A, B] extends [B, A] ? true : false;

    Однако в TS некоторые разные по сути значения могут считаться одинаковыми, например с readonly и без него:

    type Eq = EqualsV2<{readonly a: 1}, {a: 1}>; // true

    Представленный в статье Equals более строгий, и для двух разных типов всегда вернет false. Но увы, иногда он возвращает false даже для одинаковых типов (EqualsV2 здесь работает правильно):

    type Eq = Equals<{a: 1; b: 2}, {a: 1} & {b: 2}>; // false

    Насколько я знаю, прямо сейчас идеального Equals в TS нет.


    1. Konstantin_Loginovskikh Автор
      17.12.2024 13:05

      И снова большое спасибо)
      Я не знаю, кто вы, но ваши комменты бесценны (без иронии)

      PS. Да, последний пример исключение, тк пересечение является флагом для ts, а наличие/отсутствие флага воспринимается как отличающий признак (вроде как)
      Но тут спасает другая привычка - при описании комплексных типов я обычно использую подобный код

      type Prettify<T> = T extends Record<string, unknown> ? {
        [K in keyof T]: T[K];
      } : T
      

      Он нивелирует все флаги и пересечения, делая сравнение максимально точным
      Если мы добавим этот код в Equals, то, вероятно, это и будет максимально строгое сравнение


  1. radist2s
    17.12.2024 13:05

    Если вы просто тестируете типы, то подход ясный. Но если тестируете типы на данных, то обычного satisfies будет достаточно для большинства случаев.