Привет, Хабр! Это Костя из Cloud.ru, и я продолжаю цикл коротких статей с рецептами TypeScript, посвященный решению повседневных задач. Сегодня у нас в меню тип, который поможет вам выбрать только одно поле из типа. Поехали!

Постановка задачи
Представим, что у нас есть запрос на backend, который должен давать информацию о чем-либо, например, количестве сообщений в чате. И этот же метод должен показывать количество сообщений в группе чатов, а также общее количество непрочитанных сообщений у пользователя (т. е. у вас).
Все, что нужно этому методу — передать один из параметров: chatId, groupId либо userId. У нас есть автоматическая типизация из Swagger, в этом случае тип обозначен как три опциональных поля:
type GetUnreadMessages = { groupId?: string; chatId?: string; userId?: string; }
Т. е. смысл задачи в том, чтобы создать такой тип, который:
заставит пользователя вводить один из параметров;
не даст пользователю вводить второй параметр;
будет использовать сгенерированный тип.
Для нас особенно важен третий пункт из этой задачи, ведь мы не хотим писать такие типы руками, а случа�� формирования такого запроса не исключительный.
Пошаговый рецепт
Для начала давайте решим эту задачу для одного поля объекта — groupId. Для этого будем использовать коробочный утилитарный тип Record, в качестве ключа передадим ему нужный нам параметр, а в качестве значения — его значение из начального объекта. Кроме того, исключим из значения undefined, т. к. по условию это значение должно стать обязательным.
type RequireOneKey<T> = Record<'groupId', Exclude<T['groupId'], undefined>> // { // groupId: string; // }
И тут мы сразу сталкиваемся с первой проблемой — объекты с большим количеством полей тоже будут удовлетворять этому типу:
type TypeWithGroupId = { groupId: string; } const preparedPayload = { groupId: '1', chatId: '2' } const payload: TypeWithGroupId = preparedPayload; // ✅ - никаких ошибок
Чтобы избежать этого, добавим в тип все остальные поля нашего объекта, но укажем в качестве их значения undefined. И сразу можно заменить groupId на параметр:
type RequireOneKey<T, K extends keyof T> = Record<K, Exclude<T[K], undefined>> & Partial<Record<Exclude<keyof T, K>, undefined>> // { // groupId: string; // chatId?: undefined; // userId?: undefined; // }
Теперь осталось перебрать все ключи и выполнить эту операцию для каждого ключа, а все результаты объединить между собой. Для этого используем рекурсивные алгоритмы, понемногу уменьшая начальное количество ключей. Но я предлагаю взять более элегантное решение:
// Создаем новый тип-объект, с ключами как у начального типа type RequireOnlyOne<T> = { // Для каждого ключа создаем новый тип, где именно этот ключ обязательный [K in keyof T]: RequireOneKey<T, K> }[keyof T]; // В конце получаем от ts юнион всех типов
Осталось «победить» лишь один нюанс TypeScript'а — при таком решении полученный тип будет опциональным. Дело в том, что при переборе значений через [K in keyof T] мы автоматически получим и опциональность поля из перебираемого объекта. Все, что нам остается — исключить undefined из результатов. Это можно сделать несколькими способами, но в нашем случае я предлагаю использовать самый лаконичный — вычесть знак вопроса из перебора:
type RequireOnlyOne<T> = { // Просто вычитаем знак вопроса 😃 [K in keyof T]-?: RequireOneKey<T, K> }[keyof T];
Что дальше? Теперь мы можем по-разному адаптировать этот код, например:
сохранить типы других полей (не приравнивать их к undefined) — тогда мы получим RequireAtLeastOne;
передавать в этот дженерик перечень ключей, из которых нужно будет выбрать обязательные поля (например, если помимо локатора у нас будет еще фильтр или другие дополнительные параметры).
И многое другое — всё ограничено лишь вашей фантазией.
Забрать готовое блюдо
Как и всегда, в конце статьи оставляю полностью готовый код (с тест-кейсами!) для ваших будущих экспериментов:
type Prettify<T> = T extends Record<string, unknown> ? { [K in keyof T]: T[K]; } : T type RequireOneKey<T, K extends keyof T> = Record<K, Exclude<T[K], undefined>> & Partial<Record<Exclude<keyof T, K>, undefined>> type RequireOnlyOne<T> = Prettify<{ [K in keyof T]-?: RequireOneKey<T, K> }[keyof T]>; type GetUnreadMessages = { groupId?: string; chatId?: string; userId?: string; } type Test = RequireOnlyOne<GetUnreadMessages> // Тесты, чтобы поэкспериментировать type Cases = [ Expect<Extends<{userId: '2'}, Test>>, Expect<Extends<{chatId: '2'}, Test>>, Expect<Extends<{groupId: '2'}, Test>>, Expect<Extends<{groupId: '2', userId: undefined}, Test>>, Expect<Extends<Test, GetUnreadMessages>>, Expect<Not<Extends<{}, Test>>>, Expect<Not<Extends<{userId: '2', chatId: '2'}, Test>>>, Expect<Not<Extends<{userId: undefined}, Test>>>, Expect<Not<Extends<undefined, Test>>>, ] type Expect<T extends true> = T; type Extends<T, P> = T extends P ? true : false; type Not<T extends boolean> = true extends T ? false : true;
Спасибо, что дочитали до конца. Если хотите больше рецептов или разбор каких-то других смежных тем — в комментариях пишите, про что вам будет интересно почитать.
Вам может быть интересно:

