Всем привет! С вами снова Костя Логиновских — ведущий разработчик из 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>;
Спасибо, что дочитали до конца. Если хотите больше рецептов или разбор каких-то других смежных тем — в комментариях пишите, про что вам будет интересно почитать.
Интересно в блоге:
nin-jin
Что ж вы на типы тесты-то не пишете?