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