Привет, Хабр! Это Костя из 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 payload: TypeWithGroupId = {
groupId: '1',
chatId: '2'
} // ✅ - никаких ошибок
Чтобы избежать этого, добавим в тип все остальные поля нашего объекта, но укажем в качестве их значения 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;
Спасибо, что дочитали до конца. Если хотите больше рецептов или разбор каких-то других смежных тем — в комментариях пишите, про что вам будет интересно почитать.
Вам может быть интересно:
Комментарии (19)
gsaw
10.01.2025 11:21typescript творит чудеса, но меня все же напрягают такие решения, потому как, а разберется ли кто то в этом после меня, да и разберусь ли я в этом сам спустя время? Тем более брать такие готовые рецепты. Слишком они загадочны.
Я поначалу тоже копировал со stackoverflow примеры, потом через пару спринтов я уже не мог вспомнить что это за хрень такая и как я смог родить такое. Приходилось опять гуглить. Пример с union в комментариях все же много понятнее, хоть и не универсально. Да и такое решение проще в коде найти и видно, что оно делает с первого взгляда.
Konstantin_Loginovskikh Автор
10.01.2025 11:21Есть такая тема, именно из-за нее в конце статьи есть тесты на типы XD)
Если практически отвечать на этот вопрос, то такие типы не требуют поддержки, а использование в коде выглядит довольно прозрачно, extype RequestPayload = RequireOnlyOne<SwaggerRequestPayload>;
В целом, с перого раза понятно, что делает этот код, а тесты позволяют быть уверенным, что он выполняет свою работу
jbourne
10.01.2025 11:21До этого не доводилось встречать тесты типов как то. Благодарю за пример. Хороший код. Думал об этом, но руки как то не доходили посмотреть.
Да, тесты сильно добавляют понимания + помогут в поддержке через месяц. Еще понимания добавляет выделение утилитарных типов (промежуточных), как вы и делаете, а не все писать одним комбайном. В общем - сделать такие вещи понятными и поддерживаемыми явно можно и не сложно.
SumarokovVladimir
10.01.2025 11:21Ругается же)
ts: 5.2.2Alexandroppolus
10.01.2025 11:21Это из-за объектного литерала - ts из коробки не допускает в нем лишних полей. Здесь на самом деле должен быть такой код:
type TypeWithGroupId = { groupId: string; } const value = { groupId: '1', chatId: '2' }; // тут не ругается, а нам надо чтоб ругался const payload: TypeWithGroupId = value;
dom1n1k
10.01.2025 11:21Я когда вижу такие решения, у меня только один вопрос: вот это вот всё (~30 sloc и тройная вложенность
<>
) – чтобы что?
Весь хабр забит статьями в духе не мудри, не выпендривайся, читаемость почти всегда важнее... Но на мир TS это как будто не распространяется, тут наоборот доблесть навертеть похитрее.Чем плохо вместо трех опциональных полей сделать тупо и понятно?
type GetUnreadMessages = { entityType: ET_GROUP | ET_CHAT | ET_USER; entityId: string; }
Alexandroppolus
10.01.2025 11:21Поскольку это преобразование надо делать для кучи исходных типов, всё равно придется запилить type-util, который будет выглядеть примерно так же, как представленная здесь реализация. A потом везде вместо очевидного
{chatId: '1'}
писать дурацкое{entityType:'chatId', entityId:'1'}
dom1n1k
10.01.2025 11:21Так не дурацкое же, а наоборот логичное.
Потому что этот entityType явным образом говорит, что мы хотим получить. Условно говоря, в какую таблицу базы нужно сходить. А три взаимоисключающих айдишника сообщают то же самое, но неявно - на той стороне потом нужно прогнать проверки в духе if groupId != null else if chatId != null else... Это хрупкая и неудобная система, но зато с модными типами.Alexandroppolus
10.01.2025 11:21Ну ок, о вкусах спорить не буду. Однако, модный type-util для этого вашего entityType написать всё равно придется, ведь так? Потому что в исходно заданных типах его нет
dom1n1k
10.01.2025 11:21Я подразумеваю, что
ET_GROUP | ET_CHAT | ET_USER
это просто юнион трех литералов (строковых или числовых). Да, 3 строчки нужно добавить, но они абсолютно очевидны.
Raspy
10.01.2025 11:21В этом случае придётся бекенд перелопачивать. Но из плюсов тогда на сваггере можно корректно описать такой тип, ибо сваггер умеет дискриминировать типы по значению какого либо поля.
jbourne
10.01.2025 11:21Так по методу в статье это будет короче выглядеть:
type NewType = RequireOnlyOne<GetUnreadMessages>
И все понятно. Остальное будет кодом инфры. Который не нужно менять при добавлении поля.
Плюс тут типизация более естественная, а вы делаете сурогатные поля - у сущностей нет entityType и entityId.
И вы накладываете жесткие ограничения на тип
GetUnreadMessages
(его структуру). А кто вам сказал, что типGetUnreadMessages
можно менять и он может быть другим? Кто сказал, что там только айдишники? А если там или ордерАйди или юзерНейм, нужно новые имена сурогатным полям? А если завтра добавится продуктСКУ, вы будете везде переименовывать и называть еще как то более дженерик именами ваши поля?Автор выходит из того, что есть вот такая вот ситуация и конкретный формат входного типа. И показывает как это можно решить + гибко, что бы не дописывать при добавлении полей, что важно.
Вы меняете условие (меняете входной формат) и предлагаете другое решение. Ваше тоже хорошее и пользвал много раз. Оно простое и не требует никакой подготовки инфры. И наверное более стандартное. Короче протоптанная тропа, это ясно. Но это решение немного не той задачи, что автор решал.
Но хорошо, что вы указали на этот кейс. Что бы люди понимали, что когда все просто - решения нужно брать простые. И когда можно брать простые. Решение автора - это больше для фреймворков, кода инфры (если большая своя) или для команды с большим опытом ТСа. В остальных случаях скорее всего люди будут брать ваше решение или юнион как в первом комменте к статье.
dom1n1k
10.01.2025 11:21у сущностей нет entityType и entityId
Я не согласен. Уже когда мы сказали, что сущность может быть группой, чатом или юзером, мы уже подразумеваем, что у неё есть тип из этого списка. Вопрос только, введем ли мы его явно или будем извлекать из косвенных данных? Я за явное: либо вводить поле, как я написал, либо разделять три перечисленных объекта на три отдельных типа (а там где нужно, будет их юнион).
То есть дело тут не только и даже не столько в громоздких инфраструктурных типах, а ещё в дилемме явное-неявное.
Единственное оправдание, с которым я готов согласиться - так уже сделано раньше и нужно поддерживать консистентность с легаси-кодом.
jbourne
10.01.2025 11:21Согласен про явное-неявное. Самому хочется всегда все сделать четко и все границы видимыми. Мне просто способ с типами кажется не таким уж неявным. Но тут наверное уже дело привычки и вкусов в команде.
И такая ситуация не только в легаси может быть. Например ивенты от разных систем (на других языках, рантаймах, нодах, ...), а вам их обрабатывать. В общем везде, где мы не контролируем саму получаемую сущность. В различных журналах это часто.
Также можно использовать чисто внутри приложения - типа выбор фильтра для поиска, ивенты, ... Там где вы все проверите ТСом и весь ТС ваш и команде ок.
Еще во всякого рода фидах (типа главная страница/лента в соц. сетях), где сущность разного типа прут в одном листе, т.е. где естественна неоднородность отдаваемых сущностей.
Еще где я хочу сделать внешний АПИ и мне незачем наваливать пользователю еще один параметр. Я говорю ему: или групАйди или юзерАйди. Больше ничего. Не нужно никаких тайпов. С точки зрения юзера АПИ - так проще. Меньше шума. Имя филда делает разделение типов. Ему понятно все. Если для меня это важно - то способ из статьи лучше. Это фактически и приведено в статье.
Но если это способ общения со своим же Java REST, то большинство скорее всего сделает вашим способом.
В общем я к тому, что такое бывает. Это не самый популярный кейс, но бывает. Ваш - более стандартный и частый.
Abirvalg1
10.01.2025 11:21Сервис с тремя публичными и одним приватным методами. Приватный делает запрос. Публичные получают стрингу на вход. Приватный - два параметра, тип айди и его значение.
Намудрили слишком. Проще нужно быть и люди к вам потянутся.
Проверки, что ничего не сломается предоставить тестам.
aectann
А что-то вроде:
не подойдет?
Sergey2a
Такой тип "пропустит" объект, в котором заполнено сразу несколько полей. Чтобы разрешить ввод значения строго только для одного поля из трех, нужен примерно такой тип:
type GetUnreadMessages =
| ({ groupId: string } & Partial<{ chatId: undefined, userId: undefined }>)
| ({ chatId: string } & Partial<{ groupId: undefined, userId: undefined }>)
| ({ userId: string } & Partial<{ groupId: undefined, chatId: undefined }>);
Что то подобное автор и конструирует автоматически.
Konstantin_Loginovskikh Автор
В дополнение к предыдущему комменту (кстати, этот момент описан в статье), еще добавлю, что мы используем автотипы из сваггера, то есть начальный тип уже у нас есть. Нам нужно только его переработать под конкретные нужды.
Когда-нибудь, когда сваггер начнет адекватно размечать такие типы, заживем =)