Всем привет! С вами снова Костя Логиновских — ведущий разработчик из Cloud.ru. Я уже делился TypeScript-рецептами в предыдущих статьях — вот первая и вторая — и теперь хочу рассказать про еще один. Наши рецепты — это готовый код, который можно применить в конкретных ситуациях, а в некоторых случаях и подогнать ситуацию под код.

Сегодня в меню — функция на обычном TypeScript, которая преобразует тип объекта так, чтобы все ключи внутри него из snake_case стали camelCase. Жду всех под катом!

Задача: что готовим, для кого и почему

Наш бэкенд написан на Go, и коллегам удобнее всего использовать свои переменные и отправлять их нам именно в snake_case. Не будем поднимать философский вопрос о том, насколько им сложно преобразовать все свои объекты перед отправкой. Сделаем акцент на том, что можно сделать, если все, что летит в вас из ответов бэкенда, написано в неприятном для фронтендера регистре.

Раньше нас это не сильно беспокоило — мы всё равно писали свои типы сразу в camelCase указывая, что прилетает из запроса, а на уровне запросного объекта (условного аксиоса) просто ставили мидлвару с преобразованием.

Но с приходом автотипизации из сваггера, не пользоваться которой — преступление против эволюции, нам понадобилось использовать те самые типы, но с измененным регистром. Но что делать, если все типы, которые нам приходят, выглядят примерно так (все типы изменены):

type CityOne = {
  city_one: string;
};

type GetUserResponse = {
  id: string;
  user_name: string;
  user_email?: string;

  organization: {
    array_of_strings: ['string_one', CityOne]
    org_units_optional?: string[];
    addresses: CityOne[];
    addresses_two?: CityOne[];
    several_different?: [{ type_one: string }, { type_two: string }];
  }
};

Так что наш план — преобразовать тип GetUserResponseтак, чтобы все ключи в нем, включая вложенные типы, сохранили свою опциональность и изменились в camelCase. Для этого соберем все ингредиенты.

Шаг 1. Преобразовываем строку

Чтобы преобразовать все ключи объекта в новый регистр, сначала стоит научиться преобразовывать одну строку.

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

type CamelCase<S> =
  S extends `${infer First}_${infer SecondFirst}${infer Rest}`
    ? `${First}${Uppercase<SecondFirst>}${CamelCase<Rest>}`
    : S;

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

Все, что находится до нижнего подчеркивания, мы считаем сразу валидным значением для camelCase. В нашем примере такая строка — array, поэтому она сразу отправляется в результат. Затем мы берем первую букву после подчеркивания, увеличиваем ее и выбрасываем подчеркивание — у нас остается строка arrayOf_strings. Всю остальную часть —f_strings— мы отправляем по рекурсии дальше, поскольку она сама по себе является валидным snake_case.

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

Шаг 2. Обходим объект

Ключевое слово as, которое может вызывать негативные эмоции на код-ревью (поскольку обычно используется для прямого обмана TypeScript), подойдет здесь весьма кстати — выполнит роль присваивания в ключах нашего нового объекта:

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

На самом деле этот участок кода уже покрывает большинство необходимых кейсов, но здесь кроется ловушка для тех, кто хорошо знает JS. Дело в том, что в ванильном JS все ссылочные типы наследуются от объекта, а в нашем примере роль объекта играет условное выражение Textends Record<string, unknown>.

Если точнее, то Record<string, unknown>— это не просто объект, а объект с неизвестными значениями. Т. е. таким объектом не может быть другой ссылочный тип, в том числе и массив (ключами массива являются числа — только представьте их перебор!). Для того, чтобы преобразовать еще и массив, нам понадобится отдельное условие, а TypeScript как раз умеет такое делать:

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;

Получается, мы просто вычисляем тип внутри массива, после чего прокатываем его через нашу рекурсию.

Забрать рецепт

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

type CityOne = {
  city_one: string;
};

type GetUserResponse = {
  id: string;
  user_name: string;
  user_email?: string;

  organization: {
    array_of_strings: ['string_one', CityOne]
    org_units_optional?: string[];
    addresses: CityOne[];
    addresses_two?: CityOne[];
    several_different?: [{ type_one: string }, { type_two: string }];
  }
};

type CamelCase<S> =
  S extends `${infer First}_${infer SecondFirst}${infer Rest}`
    ? `${First}${Uppercase<SecondFirst>}${CamelCase<Rest>}`
    : S;

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;

type CameledRecord = KeysToCamelCase<GetUserResponse>;

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

Интересно в блоге:

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


  1. nin-jin
    01.12.2024 18:42

    Что ж вы на типы тесты-то не пишете?