При проєктуванні API бібліотек та корпоративних SDK інженери регулярно стикаються із завданням створення поліморфних функцій, поведінка та тип повернення яких залежать від типу або кількості переданих аргументів. В арсеналі TypeScript для цієї мети існують два альтернативні механізми:
T extends U ? X : Y.Хоча обидва підходи на перший погляд вирішують схоже завдання, вони володіють фундаментально різними моделями обчислення типів і поведінки при роботі з об'єднаннями (Unions).
+-------------------------------------------------------------------------+
| АРХІТЕКТУРНЕ ЗІСТАВЛЕННЯ МЕХАНІЗМІВ |
+-------------------------------------------------------------------------+
| [Підхід 1: Function Overloading] |
| function get(id: string): Entity; |
| function get(ids: string[]): Entity[]; |
| |
| Модель: Дискретний лінійний список. |
| Алгоритм: First-match (зверху вниз). |
| Поведінка з Union: ПАДІННЯ! get(param: string | string[]) -> ПОМИЛКА! |
+-------------------------------------------------------------------------+
vs
+-------------------------------------------------------------------------+
| [Підхід 2: Conditional Generics] |
| function get<T extends string | string[]>(idOrIds: T): |
| T extends string[] ? Entity[] : Entity; |
| |
| Модель: Дистрибутивне математичне відображення. |
| Алгоритм: Обчислення типу через проєкцію T. |
| Поведінка з Union: ПРАЦЮЄ! Результат: Entity | Entity[] |
+-------------------------------------------------------------------------+Різниця між механізмами криється в алгоритмі роботи компілятора:
Пастка перевантажень з Union-типами:
Сигнатури:
1) (x: string) => number
2) (x: number) => string
Виклик:
const arg: string | number = getDynamicValue();
fn(arg); // ПОМИЛКА TS2769: No overload matches this call.
Чому?
arg не є строго string (він може бути number).
arg не є строго number (він може бути string).
Компілятор не вміє автоматично синтезувати об'єднану сигнатуру!findById('1') -> Entity) або списку сутностей при передачі масиву (findById(['1', '2']) -> Entity[]).useQuery(key) без опцій повертає базові дані, а useQuery(key, { suspense: true }) гарантує, що data не може бути undefined.asDate: true.Класичний підхід із перевантаженням чудово документується через JSDoc, надаючи унікальні підказки в IDE для кожного випадку:
export interface StorageItem {
readonly key: string;
readonly value: string;
}
/**
* Перевантаження 1: Читання одиночного ключа.
* @param key - Рядковий ідентифікатор запису.
* @returns Рядкове значення або null, якщо ключ відсутній.
*/
export function getStorage(key: string): string | null;
/**
* Перевантаження 2: Пакетне читання масиву ключів.
* @param keys - Масив ідентифікаторів.
* @returns Масив об'єктів StorageItem.
*/
export function getStorage(keys: ReadonlyArray<string>): ReadonlyArray<StorageItem>;
// Сигнатура реалізації (не видна зовні)
export function getStorage(
keyOrKeys: string | ReadonlyArray<string>
): string | null | ReadonlyArray<StorageItem> {
if (Array.isArray(keyOrKeys)) {
return keyOrKeys.map((k) => ({ key: k, value: `val_${k}` }));
}
return keyOrKeys === 'missing' ? null : `val_${keyOrKeys}`;
}
// Приклади виклику:
const single = getStorage('user_id'); // Тип: string | null
const multiple = getStorage(['key1', 'key2']); // Тип: readonly StorageItem[]
// ПАСТКА ПЕРЕВАНТАЖЕННЯ:
function processQuery(input: string | string[]) {
// ❌ ПОМИЛКА КОМПІЛЯЦІЇ TS2769: No overload matches this call.
// Overload 1 requires string, Overload 2 requires ReadonlyArray<string>.
// Type 'string | string[]' is not assignable to either.
// const result = getStorage(input);
}Реалізація того ж завдання через дистрибутивний умовний тип вирішує проблему Union раз і назавжди:
export interface StorageItem {
readonly key: string;
readonly value: string;
}
export type StorageResult<T extends string | ReadonlyArray<string>> =
T extends ReadonlyArray<string> ? ReadonlyArray<StorageItem> : string | null;
/**
* Універсальна сигнатура на базі умовного дженерика.
* Підтримує як конкретні типи, так і об'єднання!
*/
export function getStorageGeneric<T extends string | ReadonlyArray<string>>(
keyOrKeys: T
): StorageResult<T> {
if (Array.isArray(keyOrKeys)) {
return keyOrKeys.map((k) => ({ key: k, value: `val_${k}` })) as StorageResult<T>;
}
const val = keyOrKeys === 'missing' ? null : `val_${keyOrKeys}`;
return val as StorageResult<T>;
}
// Перевірка викликів:
const single = getStorageGeneric('user_id'); // Тип: string | null
const multiple = getStorageGeneric(['k1', 'k2'] as const); // Тип: readonly StorageItem[]
// РОБОТА З UNION ТЕПЕР ІДЕАЛЬНА:
function processQuerySafe(input: string | string[]) {
// ✅ УСПІШНО: результат строго обчислений як:
// (string | null) | ReadonlyArray<StorageItem>
const result = getStorageGeneric(input);
}Якщо потрібні і детальні JSDoc-підказки в IDE, і підтримка об'єднань, використовується гібридний патерн — оголошення додаткової сигнатури перевантаження для Union:
// Сигнатура для одиночного елемента
export function fetchResource(id: string): Promise<Response>;
// Сигнатура для списку елементів
export function fetchResource(ids: string[]): Promise<Response[]>;
// Спеціальна сигнатура для підтримки об'єднань (Union Overload Fallback)
export function fetchResource(ids: string | string[]): Promise<Response | Response[]>;
// Реалізація
export function fetchResource(
ids: string | string[]
): Promise<Response | Response[]> {
if (Array.isArray(ids)) {
return Promise.all(ids.map((id) => fetch(`/api/${id}`)));
}
return fetch(`/api/${ids}`);
}Визначте, чи різниться кількість аргументів (Arity) між випадками. Якщо функції мають різне число параметрів (f(a) vs f(a, b, c)) — використовуйте Overloads.
Чи може аргумент надходити із зовнішнього джерела у вигляді об'єднання (string | string[])? Якщо так — чисті перевантаження зламають викликаючий код.
Для однакового числа параметрів із функціональною залежністю результату обирайте T extends Target ? R1 : R2.
Якщо обрано умовний тип, винесіть логіку в читабельний Type Alias (StorageResult<T>), щоб спливаючі підказки IntelliSense залишалися зрозумілими.
export type RawInput = string | number | boolean;
export interface NormalizedResult {
readonly raw: string;
readonly type: 'string' | 'number' | 'boolean';
readonly processedAt: number;
}
export type TransformOutput<T extends RawInput | ReadonlyArray<RawInput>> =
T extends ReadonlyArray<RawInput>
? ReadonlyArray<NormalizedResult>
: NormalizedResult;
/**
* Ізоморфний трансформер даних.
* Безшовно приймає скаляр, масив скалярів або динамічне об'єднання скалярів та масивів.
*/
export function transformInput<T extends RawInput | ReadonlyArray<RawInput>>(
input: T
): TransformOutput<T> {
const normalizeSingle = (val: RawInput): NormalizedResult => ({
raw: String(val),
type: typeof val as NormalizedResult['type'],
processedAt: Date.now(),
});
if (Array.isArray(input)) {
return (input as ReadonlyArray<RawInput>).map(normalizeSingle) as TransformOutput<T>;
}
return normalizeSingle(input as RawInput) as TransformOutput<T>;
}Компілятор TypeScript зіставляє перевантаження в порядку оголошення зверху вниз. Якщо більш загальний тип (наприклад, string або any) поставити вище більш специфічного літерала ('json'), специфічне перевантаження ніколи не буде викликано.
// ❌ АНТИПАТЕРН: Загальний string перехоплює специфічний літерал 'json'
function parseBad(format: string): unknown;
function parseBad(format: 'json'): Record<string, unknown>; // НЕДОСЯЖНА СИГНАТУРА!
// ✅ ПРАВИЛЬНО: Від найбільш специфічних типів до найбільш загальних
function parseGood(format: 'json'): Record<string, unknown>;
function parseGood(format: string): unknown;Створення красивих перевантажень для бібліотечного методу часто ламає споживчий код, якщо користувач передає змінну з типом об'єднання.
// ❌ АНТИПАТЕРН: Перевантаження без сигнатури для об'єднання
function render(node: string): HTMLElement;
function render(node: number): Text;
function wrapper(val: string | number) {
// Помилка: No overload matches this call
return render(val);
}
// ✅ ПРАВИЛЬНО: Використання дженерика з умовним типом замість двох перевантажень
function render<T extends string | number>(node: T): T extends string ? HTMLElement : Text;Спроба закодувати складний скінченний автомат в один нечитабельний тернарний тип у сигнатурі робить код непідтримуваним. Складні обчислення типів завжди повинні виноситися в іменовані допоміжні типи (Type Aliases).
// ❌ АНТИПАТЕРН: Нечитабельна локшина тернарних операторів безпосередньо в аргументі
function bad<T>(x: T): T extends A ? (T extends B ? C : D) : (T extends E ? F : G) { ... }
// ✅ ПРАВИЛЬНО: Декомпозиція в іменований утилітний тип
type ResolveOutput<T> = ...;
function good<T>(x: T): ResolveOutput<T> { ... }Якщо сигнатура реалізації функції оголошена занадто вузько або несумісна із сигнатурами перевантажень, компілятор видасть помилку TS2394: This overload signature is not compatible with its implementation signature.
// ❌ АНТИПАТЕРН: Реалізація не покриває перевантаження #2
function test(x: string): string;
function test(x: number): number;
function test(x: string): any { ... } // Помилка TS2394: number не покрито!Якщо різні варіанти функції приймають принципово різну кількість параметрів — обирайте Function Overloads.
Якщо аргументи мають фіксовану довжину, а вихідний тип строго залежить від типу входу — використовуйте Generic + Conditional Type.
Якщо викликаючий код оперує динамічними типами-об'єднаннями — умовний тип обов'язковий для запобігання помилкам TS2769.
Якщо кожен кейс вимагає унікального опису параметрів у JSDoc — оформіть гібридний підхід з Overloads.
Напишіть тести на зіставлення типів із перевіркою передачі конкретних літералів та складних об'єднань.