Skip to main content
Практика завдань

Дискриміновані об'єднання в TypeScript

Що таке Дискриміновані Об'єднання?

Дискриміновані Об'єднання (також відомі як Позначені Об'єднання, Алгебраїдні Типи Даних або Сумарні Типи) — це патерн TypeScript, де кожен тип в об'єднанні має спільну властивість з літеральним типом, яка використовується для розрізнення між типами.


Основний Приклад

typescript
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 є дискримінантом, спільною властивістю з літеральними типами.


Використання з Звуженням Типів

typescript
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.


Чому Використовувати Дискриміновані Об'єднання?

Безпека Типів

typescript
// Без дискримінованих об'єднань 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; // Що це? } // З дискримінованими об'єднаннями - все є явним і безпечним за типами

Моделювання Станів

typescript
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 'Завантаження...'; case 'success': return `Дані: ${state.data}`; case 'error': return `Помилка: ${state.error}`; } }

Перевірка Вичерпності

TypeScript може перевірити, що ми обробили всі можливі випадки.

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; } }

Якщо ми додамо новий тип дії, TypeScript видасть помилку:

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: // Помилка: Тип 'MULTIPLY' не може бути присвоєний типу 'never' const _exhaustiveCheck: never = action; return _exhaustiveCheck; } }

Практичні Патерни

Відповідь API

typescript
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 }

Форми та Валідація

typescript
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>Заповніть форму</div>; case 'validating': return <div>Валідація {formState.fieldName}...</div>; case 'valid': return <div>Форма дійсна! {JSON.stringify(formState.values)}</div>; case 'invalid': return ( <div> Помилки: {Object.entries(formState.errors).map(([field, error]) => ( <div key={field}>{field}: {error}</div> ))} </div> ); } }

Повідомлення WebSocket

typescript
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(`Підключено з сесією: ${message.sessionId}`); break; case 'message': console.log(`${message.author}: ${message.content}`); break; case 'user_joined': console.log(`${message.username} приєднався`); break; case 'user_left': console.log(`${message.username} покинув`); break; case 'disconnected': console.log(`Відключено: ${message.reason}`); break; } }

Дії в стилі Redux

typescript
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 }; } }

Вкладені Дискриміновані Об'єднання

typescript
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(`Помилка мережі: ${result.error.statusCode}`); break; case 'validation': console.error('Помилки валідації:', result.error.fieldErrors); break; case 'auth': console.error(`Помилка авторизації: ${result.error.reason}`); break; } } }

Створення Допоміжних Функцій

typescript
// Безпечні за типами функції-створювачі дій 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('Купити молоко'); // тип: Action

Витягування Типів з Дискримінованих Об'єднань

typescript
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 } // Витягнути навантаження type ActionPayload<T extends Action['type']> = ExtractAction<T> extends { value: infer P } ? P : never; type SetValuePayload = ActionPayload<'SET_VALUE'>; // number

Загальні Помилки

Забування Літеральних Типів

typescript
// Погано - тип занадто широкий type BadShape = { kind: string; // Має бути літеральним! radius: number; }; // Добре type GoodShape = { kind: 'circle'; // Літеральний тип radius: number; };

Непослідовні Дискримінанти

typescript
// Погано - різні назви для дискримінанта 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 };

Необов'язковий Дискримінант

typescript
// Погано type BadAction = { type?: 'INCREMENT'; // Необов'язковий! }; // Добре type GoodAction = { type: 'INCREMENT'; // Обов'язковий };

Переваги

  • Безпека типів на етапі компіляції
  • Автоматичне звуження типів
  • Перевірка вичерпності
  • Читабельний та зрозумілий код
  • Не потрібно використовувати асерції типів
  • Легко розширювати новими випадками
  • Відмінна підтримка рефакторингу

Висновок

Дискриміновані Об'єднання:

  • Патерн для моделювання взаємно виключних станів
  • Вимагають спільну властивість з літеральними типами (дискримінант)
  • Автоматичне звуження типів в switch та if
  • Перевірка вичерпності через never
  • Ідеально підходять для станів, відповідей API, дій
  • Краще, ніж необов'язкові пропси для моделювання варіантів

На Співбесідах:

Важливо вміти:

  • Пояснити, що таке дискриміновані об'єднання та дискримінант
  • Показати приклад зі звуженням типів
  • Реалізувати перевірку вичерпності
  • Надати практичні приклади (API, дії Redux, стани)
  • Пояснити переваги над необов'язковими властивостями
  • Показати, як витягувати типи з об'єднання

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?
Практика завдань