Pull to refresh
97.55
Cloud.ru
Провайдер облачных сервисов и AI-технологий

Рецепты Typescript: выбор одного и только одного обязательного поля в объекте

Level of difficultyMedium
Reading time4 min
Views3.8K

Привет, Хабр! Это Костя из 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;

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

Вам может быть интересно:

Tags:
Hubs:
+9
Comments23

Articles

Information

Website
cloud.ru
Registered
Founded
2019
Employees
1,001–5,000 employees
Location
Россия
Representative
Елизавета