Дискриміновані об'єднання в 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.53981633974483TypeScript автоматично звужує тип в кожній гілці 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
Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.