Avatar ·

TypeScript: как сделать интерфейс, в котором обязательно одно из двух свойств?

Всем привет! Пишу на TypeScript приложение для обработки заказов. У меня есть интерфейс для пользователя, который может быть либо зарегистрированным (и тогда у него есть `userId`), либо гостем (и тогда у него есть `guestToken`). Но самое главное — у пользователя должно быть ровно одно из этих двух полей, а не оба сразу и не ни одного. Вот как я пытался это сделать: ```typescript interface User { name: string; email: string; userId?: number; guestToken?: string; } ``` Но проблема в том, что TypeScript позволяет создать объект, где оба поля отсутствуют, или оба присутствуют одновременно. Например: ```typescript const user1: User = { name: "Анна", email: "a@mail.ru" }; // Нет ни userId, ни guestToken — ошибка не выдается const user2: User = { name: "Иван", email: "i@mail.ru", userId: 123, guestToken: "abc" }; // Есть оба поля — тоже ошибки нет ``` Мне нужно, чтобы компилятор строго проверял: либо есть `userId` (и нет `guestToken`), либо есть `guestToken` (и нет `userId`). Как правильно описать такой интерфейс? Пробовал читать про union types и discriminated unions, но не до конца понимаю, как применить к этой задаче. Буду благодарен за примеры кода и объяснения. <<>>

Решение через объединение типов (Union Types) с оператором never

Ваша задача — классический пример использования размеченных объединений (discriminated unions) в TypeScript. Основная идея: создать два отдельных интерфейса, каждый из которых содержит только одно из обязательных полей, а затем объединить их через | (union). Чтобы запретить наличие второго поля, используем вспомогательный тип Never.

Вот полное решение:

// Вспомогательный тип: запрещает наличие свойства
type Never<T> = {
  [K in keyof T]?: never;
};

// Интерфейс для зарегистрированного пользователя
interface RegisteredUser {
  name: string;
  email: string;
  userId: number;
  guestToken?: never; // явно запрещаем guestToken
}

// Интерфейс для гостя
interface GuestUser {
  name: string;
  email: string;
  userId?: never; // явно запрещаем userId
  guestToken: string;
}

// Финальный тип: или зарегистрированный пользователь, или гость
type User = RegisteredUser | GuestUser;

Как это работает?

  • Ключевое слово never в TypeScript означает "пустой тип" — значение, которого не может существовать. Указывая guestToken?: never, мы говорим: это свойство может быть только undefined (благодаря ?), но если вы попытаетесь присвоить ему любое значение (кроме undefined), TypeScript выдаст ошибку.
  • Объединение | создает тип, который может быть либо RegisteredUser, либо GuestUser. При проверке TypeScript будет требовать, чтобы объект соответствовал одному из этих интерфейсов.

Примеры использования

// ✅ Работает: зарегистрированный пользователь
const user1: User = {
  name: "Анна",
  email: "a@mail.ru",
  userId: 123
};

// ✅ Работает: гость
const user2: User = {
  name: "Иван",
  email: "i@mail.ru",
  guestToken: "abc123"
};

// ❌ Ошибка: нет обязательных полей
const user3: User = {
  name: "Петр",
  email: "p@mail.ru"
};
// Ошибка: Type '{ name: string; email: string; }' is not assignable to type 'User'.

// ❌ Ошибка: оба поля присутствуют
const user4: User = {
  name: "Мария",
  email: "m@mail.ru",
  userId: 456,
  guestToken: "xyz"
};
// Ошибка: Type '{ ... userId: number; guestToken: string; }' is not assignable to type 'User'.

Проверка внутри функции (type narrowing)

При работе с объединенными типами TypeScript помогает сужать тип через проверку наличия поля:

function processUser(user: User) {
  if ("userId" in user) {
    // Здесь TypeScript знает, что user — RegisteredUser
    console.log(`Зарегистрированный пользователь с ID: ${user.userId}`);
  } else {
    // Здесь TypeScript знает, что user — GuestUser
    console.log(`Гость с токеном: ${user.guestToken}`);
  }
}

Альтернатива: общий интерфейс с never

Если вы предпочитаете один интерфейс, можно использовать такой трюк:

interface UserBase {
  name: string;
  email: string;
}

type User = (UserBase & { userId: number; guestToken?: never }) |
            (UserBase & { userId?: never; guestToken: string });

Этот вариант более компактный, но менее читаемый. Я рекомендую первый подход с отдельными интерфейсами — он проще для понимания и поддержки.

<<>>

Решение через условные типы (Conditional Types)

Другой подход — использовать обобщенный тип, который на основе флага выбирает нужную структуру. Этот метод полезен, если у вас много вариантов или нужно переиспользовать логику.

// Определяем возможные варианты
type UserType = 'registered' | 'guest';

// Условный тип, который выбирает поля в зависимости от типа
type User<T extends UserType> = {
  name: string;
  email: string;
} & (T extends 'registered' 
    ? { userId: number; guestToken?: never }
    : { userId?: never; guestToken: string });

// Использование с явным указанием типа
const user1: User<'registered'> = {
  name: "Анна",
  email: "a@mail.ru",
  userId: 123
};

const user2: User<'guest'> = {
  name: "Иван",
  email: "i@mail.ru",
  guestToken: "abc"
};

Плюсы и минусы

  • + Явное указание типа делает код самодокументируемым.
  • + Легко добавить третий вариант (например, 'admin').
  • - Нужно всегда передавать тип при создании объекта.
  • - Менее удобно для функций, которые принимают «любого пользователя».

Если вам нужно больше гибкости, комбинируйте этот подход с дискриминантным объединением (добавьте поле type в интерфейс). Это позволит TypeScript автоматически сужать тип при проверке значения type.