При проектировании 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).
Компилятор не умеет автоматически синтезировать объединенную сигнатуру!Если разные варианты функции принимают принципиально разное количество параметров — выбирайте Function Overloads.
Если аргументы имеют фиксированную длину, а выходной тип строго зависит от типа входа — используйте Generic + Conditional Type.
Если вызывающий код оперирует динамическими типами-объединениями — условный тип обязателен для предотвращения ошибок TS2769.
Если каждый кейс требует уникального описания параметров в JSDoc — оформите гибридный подход с Overloads.
Напишите тесты на сопоставление типов с проверкой передачи конкретных литералов и сложных объединений.
Почему вызов fn(val), где val имеет тип string | number, падает с ошибкой TS2769 (No overload matches this call), если объявлены перегрузки function fn(x: string): number и function fn(x: 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>;
}Что произойдет, если в списке перегрузок объявить сигнатуру function parse(x: string): unknown выше сигнатуры function parse(x: 'json'): Config?
Компилятор 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 не покрыт!