TypeScript Function Overloading: почему это не «магия», а инструмент для умного кода
Если вы когда-нибудь писали на JavaScript функцию, которая принимает «то строку, то число, а то и массив», вы знаете этот неприятный момент: внутри функции — ад из if, а снаружи — полная неопределённость для того, кто эту функцию вызывает. TypeScript решает эту проблему элегантно, но не всегда очевидно. Перегрузка функций (function overloading) — это не про создание нескольких функций с одинаковым именем (как в Java или C++). Это про объявление нескольких сигнатур для одной реализации.
В 2025 году, когда TypeScript стал стандартом де-факто для серьёзных фронтенд- и бэкенд-проектов, умение грамотно настраивать перегрузку — это не «фишка», а навык, который делает ваш код читаемым, типобезопасным и удобным для команды. Давайте разберёмся, как это работает, на чём ломаются новички и какие паттерны стоит взять на вооружение.
Что такое перегрузка функций на самом деле?
Представьте, что вы пишете функцию formatDate. Она должна уметь:
- Принять объект
Dateи вернуть строку. - Принять строку в формате ISO и вернуть строку.
- Принять число (timestamp) и вернуть строку.
Если просто написать function formatDate(input: Date | string | number): string, TypeScript не сможет подсказать, какой именно тип вы передаёте в конкретном вызове. IDE покажет общий тип, и вы потеряете автодополнение. Перегрузка решает это:
// Сигнатуры перегрузки (overload signatures)
function formatDate(input: Date): string;
function formatDate(input: string): string;
function formatDate(input: number): string;
// Реализация (implementation signature)
function formatDate(input: Date | string | number): string {
if (input instanceof Date) {
return input.toISOString();
} else if (typeof input === 'string') {
return new Date(input).toISOString();
} else {
return new Date(input).toISOString();
}
}
Теперь, когда вы вызываете formatDate(new Date()), TypeScript знает, что возвращается string, и подсказывает типы. Магии нет — есть строгая типизация.
Правило №1: сигнатуры перегрузки не могут быть реализацией
Самая частая ошибка новичков — пытаться написать тело функции в каждой сигнатуре. Не делайте так:
// ❌ Ошибка: дублирование реализации
function foo(x: number): number {
return x * 2;
}
function foo(x: string): string {
return x.toUpperCase();
}
TypeScript не поддерживает множественные реализации с одинаковым именем. Всегда одна реализация, несколько объявлений.
Правило №2: порядок сигнатур имеет значение
TypeScript проверяет сигнатуры сверху вниз. Первая подходящая сигнатура определяет возвращаемый тип. Поэтому:
- Более конкретные типы ставьте выше (например,
stringпередany). - Избегайте пересекающихся сигнатур, которые могут сбить компилятор.
// Хорошо: сначала конкретный случай
function createButton(label: string, disabled?: boolean): HTMLButtonElement;
function createButton(options: { label: string; disabled?: boolean }): HTMLButtonElement;
function createButton(labelOrOptions: string | { label: string; disabled?: boolean }, disabled?: boolean): HTMLButtonElement {
// реализация
}
Правило №3: используйте Union Types, если сигнатур больше 3
Перегрузка — мощный инструмент, но не злоупотребляйте им. Если у вас 5+ сигнатур, код становится нечитаемым. В 2025 году хорошей практикой считается:
- До 3 сигнатур — используйте перегрузку.
- Больше 3 — подумайте о
Union Typeили объекте-конфигурации.
// Вместо 4 перегрузок:
function fetchData(url: string): Promise<Response>;
function fetchData(url: string, method: 'GET'): Promise<Response>;
function fetchData(url: string, method: 'POST', body: unknown): Promise<Response>;
function fetchData(url: string, method: 'PUT', body: unknown): Promise<Response>;
// ...лучше так:
type FetchOptions = {
method: 'GET' | 'POST' | 'PUT';
body?: unknown;
};
function fetchData(url: string, options?: FetchOptions): Promise<Response>;
Типичные грабли 2025: что пошло не так?
Вот несколько ситуаций, которые мы регулярно видим в коде на Stack Overflow и в реальных проектах:
1. Забыли сделать реализацию совместимой со всеми сигнатурами
Реализация должна быть «супертипом» для всех сигнатур. Если сигнатура принимает string, а реализация — только number, TypeScript выдаст ошибку.
// ❌ Ошибка: реализация несовместима
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: number, b: number): number { // не хватает string
return a + b;
}
2. Перегрузка с опциональными параметрами
Если параметр опционален в одной сигнатуре, но обязателен в другой — будьте аккуратны с порядком и типами.
// ✅ Правильно: используем union для опциональности
function greet(name: string): string;
function greet(name: string, greeting?: string): string;
function greet(name: string, greeting?: string): string {
return greeting ? `${greeting}, ${name}` : `Hello, ${name}`;
}
Продвинутый паттерн: перегрузка с дженериками
В 2025 году редко кто пишет перегрузки без дженериков. Они позволяют сохранить связь между типом входа и выхода. Например, функция, которая принимает массив или одиночное значение и возвращает соответствующий тип:
// Сигнатуры с дженериками
function wrap<T>(value: T): T[];
function wrap<T>(value: T[]): T[][];
function wrap<T>(value: T | T[]): T[] | T[][] {
return Array.isArray(value) ? [value] : [value];
}
// Использование:
const a = wrap('hello'); // тип: string[]
const b = wrap([1, 2, 3]); // тип: number[][]
Это мощный инструмент для библиотек и API, где нужно сохранить точность типов без потери гибкости.
Когда перегрузка НЕ нужна: альтернативы 2025
Не каждая функция с разными аргументами требует перегрузки. Рассмотрите:
- Объект-конфигурация — для функций с 3+ параметрами.
- Rest parameters — если количество аргументов неизвестно.
- Условные типы (Conditional Types) — для сложной логики на уровне типов.
// Вместо перегрузки: используем условный тип
type Return