Hack Frontend Community

Discriminated Unions в TypeScript

Что такое Discriminated Unions?

Discriminated Unions (Размеченные объединения) — это паттерн в TypeScript, где каждый тип в union имеет общее свойство с литеральным типом, которое используется для различения (discriminating) типов.

Также известны как: Tagged Unions, Algebraic Data Types, Sum Types.


Базовый пример

type Circle = {
  kind: 'circle';
  radius: number;
};

type Square = {
  kind: 'square';
  size: number;
};

type Rectangle = {
  kind: 'rectangle';
  width: number;
  height: number;
};

type Shape = Circle | Square | Rectangle;

Здесь kind — это discriminant (дискриминант), общее свойство с литеральными типами.


Использование с Type Narrowing

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // TypeScript знает, что это Circle
      return Math.PI * shape.radius ** 2;
    
    case 'square':
      // TypeScript знает, что это Square
      return shape.size ** 2;
    
    case 'rectangle':
      // TypeScript знает, что это Rectangle
      return shape.width * shape.height;
  }
}

const circle: Circle = { kind: 'circle', radius: 5 };
console.log(getArea(circle));  // 78.53981633974483

TypeScript автоматически сужает тип в каждой ветке switch.


Зачем нужны Discriminated Unions?

Безопасность типов

// Без discriminated unions
type ShapeBad = {
  radius?: number;
  size?: number;
  width?: number;
  height?: number;
};

function getAreaBad(shape: ShapeBad): number {
  if (shape.radius) {
    return Math.PI * shape.radius ** 2;
  }
  if (shape.size) {
    return shape.size ** 2;
  }
  if (shape.width && shape.height) {
    return shape.width * shape.height;
  }
  return 0;  // Что это?
}

// С discriminated unions - все явно и типобезопасно

Моделирование состояний

type LoadingState = {
  status: 'loading';
};

type SuccessState = {
  status: 'success';
  data: string;
};

type ErrorState = {
  status: 'error';
  error: string;
};

type State = LoadingState | SuccessState | ErrorState;

function renderUI(state: State) {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    
    case 'success':
      return `Data: ${state.data}`;
    
    case 'error':
      return `Error: ${state.error}`;
  }
}

Exhaustiveness Checking

TypeScript может проверить, что мы обработали все возможные случаи.

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case 'RESET':
      return action.value;
    default:
      // Проверка на полноту обработки
      const _exhaustiveCheck: never = action;
      return _exhaustiveCheck;
  }
}

Если добавить новый тип action, TypeScript выдаст ошибку:

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; value: number }
  | { type: 'MULTIPLY'; factor: number };  // Новый тип

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case 'RESET':
      return action.value;
    default:
      // Ошибка: Type 'MULTIPLY' is not assignable to type 'never'
      const _exhaustiveCheck: never = action;
      return _exhaustiveCheck;
  }
}

Практические паттерны

API Response

type ApiSuccess<T> = {
  status: 'success';
  data: T;
  timestamp: number;
};

type ApiError = {
  status: 'error';
  message: string;
  code: number;
};

type ApiResponse<T> = ApiSuccess<T> | ApiError;

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    const data = await response.json();
    return {
      status: 'success',
      data,
      timestamp: Date.now()
    };
  } catch (error) {
    return {
      status: 'error',
      message: error.message,
      code: 500
    };
  }
}

// Использование
const response = await fetchUser(1);

if (response.status === 'success') {
  console.log(response.data);  // User
} else {
  console.error(response.message);  // string
}

Формы и валидация

type FormIdle = {
  state: 'idle';
};

type FormValidating = {
  state: 'validating';
  fieldName: string;
};

type FormValid = {
  state: 'valid';
  values: Record<string, any>;
};

type FormInvalid = {
  state: 'invalid';
  errors: Record<string, string>;
};

type FormState = FormIdle | FormValidating | FormValid | FormInvalid;

function FormComponent({ formState }: { formState: FormState }) {
  switch (formState.state) {
    case 'idle':
      return <div>Fill out the form</div>;
    
    case 'validating':
      return <div>Validating {formState.fieldName}...</div>;
    
    case 'valid':
      return <div>Form is valid! {JSON.stringify(formState.values)}</div>;
    
    case 'invalid':
      return (
        <div>
          Errors:
          {Object.entries(formState.errors).map(([field, error]) => (
            <div key={field}>{field}: {error}</div>
          ))}
        </div>
      );
  }
}

WebSocket сообщения

type ConnectedMessage = {
  type: 'connected';
  sessionId: string;
};

type MessageReceived = {
  type: 'message';
  content: string;
  author: string;
  timestamp: number;
};

type UserJoined = {
  type: 'user_joined';
  username: string;
};

type UserLeft = {
  type: 'user_left';
  username: string;
};

type DisconnectedMessage = {
  type: 'disconnected';
  reason: string;
};

type WebSocketMessage = 
  | ConnectedMessage
  | MessageReceived
  | UserJoined
  | UserLeft
  | DisconnectedMessage;

function handleMessage(message: WebSocketMessage) {
  switch (message.type) {
    case 'connected':
      console.log(`Connected with session: ${message.sessionId}`);
      break;
    
    case 'message':
      console.log(`${message.author}: ${message.content}`);
      break;
    
    case 'user_joined':
      console.log(`${message.username} joined`);
      break;
    
    case 'user_left':
      console.log(`${message.username} left`);
      break;
    
    case 'disconnected':
      console.log(`Disconnected: ${message.reason}`);
      break;
  }
}

Redux-style Actions

type FetchUsersRequest = {
  type: 'FETCH_USERS_REQUEST';
};

type FetchUsersSuccess = {
  type: 'FETCH_USERS_SUCCESS';
  payload: User[];
};

type FetchUsersFailure = {
  type: 'FETCH_USERS_FAILURE';
  error: string;
};

type Action = FetchUsersRequest | FetchUsersSuccess | FetchUsersFailure;

interface State {
  users: User[];
  loading: boolean;
  error: string | null;
}

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_USERS_REQUEST':
      return {
        ...state,
        loading: true,
        error: null
      };
    
    case 'FETCH_USERS_SUCCESS':
      return {
        ...state,
        loading: false,
        users: action.payload
      };
    
    case 'FETCH_USERS_FAILURE':
      return {
        ...state,
        loading: false,
        error: action.error
      };
  }
}

Вложенные Discriminated Unions

type NetworkError = {
  kind: 'network';
  statusCode: number;
};

type ValidationError = {
  kind: 'validation';
  fieldErrors: Record<string, string>;
};

type AuthError = {
  kind: 'auth';
  reason: 'expired' | 'invalid';
};

type AppError = NetworkError | ValidationError | AuthError;

type SuccessResult<T> = {
  status: 'success';
  data: T;
};

type ErrorResult = {
  status: 'error';
  error: AppError;
};

type Result<T> = SuccessResult<T> | ErrorResult;

function handleResult<T>(result: Result<T>) {
  if (result.status === 'success') {
    console.log(result.data);
  } else {
    // Вложенное сужение
    switch (result.error.kind) {
      case 'network':
        console.error(`Network error: ${result.error.statusCode}`);
        break;
      
      case 'validation':
        console.error('Validation errors:', result.error.fieldErrors);
        break;
      
      case 'auth':
        console.error(`Auth error: ${result.error.reason}`);
        break;
    }
  }
}

Создание helper функций

// Action creators с типобезопасностью
type Action = 
  | { type: 'ADD_TODO'; text: string }
  | { type: 'TOGGLE_TODO'; id: number }
  | { type: 'DELETE_TODO'; id: number };

// Функции-создатели действий
const addTodo = (text: string): Action => ({
  type: 'ADD_TODO',
  text
});

const toggleTodo = (id: number): Action => ({
  type: 'TOGGLE_TODO',
  id
});

const deleteTodo = (id: number): Action => ({
  type: 'DELETE_TODO',
  id
});

// Использование
const action = addTodo('Buy milk');  // type: Action

Извлечение типов из Discriminated Unions

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET_VALUE'; value: number };

// Извлечь конкретный тип по дискриминанту
type ExtractAction<T extends Action['type']> = Extract<Action, { type: T }>;

type IncrementAction = ExtractAction<'INCREMENT'>;
// { type: 'INCREMENT' }

type SetValueAction = ExtractAction<'SET_VALUE'>;
// { type: 'SET_VALUE'; value: number }

// Извлечь payload
type ActionPayload<T extends Action['type']> = ExtractAction<T> extends { value: infer P }
  ? P
  : never;

type SetValuePayload = ActionPayload<'SET_VALUE'>;  // number

Типичные ошибки

Забыть про literal types

// Плохо - тип слишком широкий
type BadShape = {
  kind: string;  // Должен быть литерал!
  radius: number;
};

// Хорошо
type GoodShape = {
  kind: 'circle';  // Литеральный тип
  radius: number;
};

Несогласованные дискриминанты

// Плохо - разные имена для дискриминанта
type Circle = { kind: 'circle'; radius: number };
type Square = { type: 'square'; size: number };  // type вместо kind!

// Хорошо - единое имя
type Circle = { kind: 'circle'; radius: number };
type Square = { kind: 'square'; size: number };

Опциональный дискриминант

// Плохо
type BadAction = {
  type?: 'INCREMENT';  // Опциональный!
};

// Хорошо
type GoodAction = {
  type: 'INCREMENT';  // Обязательный
};

Преимущества

  • Типобезопасность во время компиляции
  • Автоматическое сужение типов
  • Exhaustiveness checking
  • Читаемый и понятный код
  • Нет необходимости в type assertions
  • Легко расширять новыми случаями
  • Отличная поддержка рефакторинга

Вывод

Discriminated Unions:

  • Паттерн для моделирования взаимоисключающих состояний
  • Требуют общее свойство с литеральными типами (дискриминант)
  • Автоматическое сужение типов в switch и if
  • Exhaustiveness checking через never
  • Идеальны для состояний, API responses, actions
  • Лучше optional properties для моделирования вариантов

На собеседовании:

Важно уметь:

  • Объяснить, что такое discriminated unions и discriminant
  • Показать пример с type narrowing
  • Реализовать exhaustiveness checking
  • Привести практические примеры (API, Redux actions, состояния)
  • Объяснить преимущества перед optional properties
  • Показать, как извлекать типы из union