Перегрузки функций
Как перегрузки выставляют несколько сигнатур вызова над одной скрытой реализацией, как компилятор разрешает вызов сверху вниз и берёт первое совпадение, и когда один дженерик или условный возврат лучше перегрузок.
Коллега выкатывает хелпер createElement(tag). Для createElement("a") он хочет HTMLAnchorElement; для createElement("canvas") — HTMLCanvasElement; для любой неизвестной строки — HTMLElement. Одна сигнатура, возвращающая HTMLElement, теряет точность по тегу, а возврат-объединение заставляет каждого вызывающего заново сужать тип. Перегрузки — это инструмент, который позволяет одной функции выставить сразу несколько точных контрактов, но только если вы точно понимаете, как компилятор выбирает, какой контракт достанется конкретному вызову.
Форма: много сигнатур, одно тело
Перегрузка функции — это несколько сигнатур вызова, поставленных прямо над одной сигнатурой реализации. Сигнатуры вызова — это то, что видят вызывающие. Сигнатура реализации — это то, против чего проверяется тело, и снаружи она невидима.
// Сигнатуры перегрузки (публичный контракт)
function len(x: string): number;
function len(x: unknown[]): number;
// Сигнатура реализации (внутренняя — НЕ вызываемая перегрузка)
function len(x: string | unknown[]): number {
return x.length;
}
len("hello");
// ^? number
len([1, 2, 3]);
// ^? number
len(123);
// Error: No overload matches this call.
// Argument of type 'number' is not assignable to parameter of type 'string'.Третий вызов падает, хотя параметр реализации — string | unknown[], а 123 не подходит ни под одну сигнатуру вызова. Ключевое правило: вызывающие могут вызывать только сигнатуры перегрузки. Сигнатура реализации расширяет взгляд тела, но не входит в публичный тип.
Порядок разрешения: сверху вниз, побеждает первое совпадение
Почему порядок вообще важен? Потому что компилятор не ищет наиболее подходящую перегрузку — он останавливается на первой подходящей, и если поставить общую сигнатуру выше специфичной, специфичная молча становится недостижимой.
Когда вы пишете вызов, компилятор идёт по списку перегрузок с верха и связывает вызов с первой сигнатурой, чьи параметры принимают аргументы. Он не ищет «лучшее» совпадение — берёт первое подходящее. Поэтому порядок важен: ставьте более специфичные сигнатуры выше более общих.
function pick(x: "a"): 1;
function pick(x: string): 2;
function pick(x: string): 1 | 2 {
return x === "a" ? 1 : 2;
}
pick("a");
// ^? 1 — первая сигнатура совпала, поиск остановлен
pick("b");
// ^? 2 — первая не совпала с литералом, вторая совпадаетПоменяйте две сигнатуры местами — и pick("a") разрешится в 2, потому что string (теперь первый) уже принимает "a", и поиск никогда не доходит до литеральной перегрузки.
Сигнатура реализации должна покрывать каждую перегрузку
Тело проверяется против сигнатуры реализации, поэтому эта сигнатура должна быть совместима — быть супертипом — каждой перегрузки. Параметры каждой перегрузки должны быть присваиваемы параметрам реализации, а возврат каждой перегрузки — возврату реализации. Если сигнатура реализации слишком узкая, перегрузки, которые в неё не вписываются, отвергаются.
function f(x: string): string;
function f(x: number): number;
function f(x: string): string {
// ^^^^^^^^^ Error: This overload signature is not compatible
// with its implementation signature.
return x;
}Здесь перегрузка x: number обещает принимать число, но реализация принимает только string. Фикс — расширить параметр реализации до string | number (и обычно возврат до string | number). Сигнатура реализации не проверяется против вызывающих — она может быть свободнее, — но проверяется против перегрузок, которые она поддерживает.
▸Почему это работает
Почему сигнатура реализации не вызывается? Потому что она существует, чтобы типизировать тело, а не интерфейс. Будь она вызываемой, перегруженный len(x: string | unknown[]) пропускал бы len(maybeStringOrArray) даже тогда, когда вы хотели запретить объединение в точке вызова. Удержание реализации приватной позволяет публичным сигнатурам быть строго уже того, что технически терпит тело — и в этом весь смысл перегрузок.
Когда перегрузки выигрывают у возврата-объединения, а когда проигрывают
Перегрузки оправдывают себя, когда тип возврата зависит от конкретного типа аргумента так, как простое объединение выразить не может. createElement("a") → HTMLAnchorElement против createElement("canvas") → HTMLCanvasElement — канонический случай: одна сигнатура с возвратом HTMLElement заставила бы каждого вызывающего делать каст.
Но у перегрузок есть реальная цена: они не связывают параметр и возврат обобщённо, поэтому вызов с аргументом-объединением не совпадает ни с одной перегрузкой; они многословны и легко становятся ненадёжными. Часто чище один дженерик или условный тип возврата:
// Перегрузки: три сигнатуры, никакой связи, переиспользуемой компилятором
function wrapO(x: string): string[];
function wrapO(x: number): number[];
function wrapO(x: string | number): (string | number)[] {
return [x];
}
// Один дженерик: связывает вход и выход один раз, масштабируется на любой T
function wrap<T>(x: T): T[] {
return [x];
}
wrap("hi");
// ^? string[]
wrap(42);
// ^? number[]
const u: string | number = Math.random() > 0.5 ? "x" : 1;
wrapO(u);
// Error: No overload matches this call — аргумент-объединение не подходит ни одной перегрузке.
wrap(u);
// ^? (string | number)[] — дженерик справляется с объединением даромБеритесь за перегрузки только когда отображение вход/выход дискретное и нерегулярное (разные литеральные входы → несвязанные типы выхода). Когда отображение однородное, предпочтите дженерик; когда оно по правилу над типом, предпочтите условный тип возврата. Оба композируются лучше и остаются надёжными.
Вы пишете сигнатуры перегрузки `(x: string): 'wide'`, затем `(x: 'id'): 'narrow'` (в таком порядке) с совместимой реализацией. Во что разрешится `f('id')` и почему?
Расставьте шаги, которые компилятор делает, типизируя вызов перегруженной функции.
- 1 Собрать сигнатуры перегрузки (вызова) в порядке исходника, игнорируя сигнатуру реализации
- 2 Начать с первой сигнатуры перегрузки
- 3 Проверить, присваиваемы ли аргументы вызова параметрам этой сигнатуры
- 4 На первой совпавшей сигнатуре связать вызов и остановить поиск
- 5 Если ни одна перегрузка не совпала, сообщить 'No overload matches this call'
- 01Проследите, как именно компилятор разрешает вызов перегруженной функции, и почему важен порядок перегрузок.
- 02Когда стоит браться за перегрузки, а когда дженерик или условный тип возврата — лучший инструмент?
Перегрузки функций выставляют несколько публичных сигнатур вызова над одной приватной сигнатурой реализации. Компилятор разрешает вызов, проходя сигнатуры перегрузки сверху вниз и связывая с первой, принимающей аргументы, — первое совпадение, а не лучшее, — поэтому специфичные сигнатуры должны идти перед общими. Сигнатура реализации должна быть супертипом каждой перегрузки и никогда не вызывается снаружи функции. Используйте перегрузки только для дискретных нерегулярных отображений вход→выход; иначе берите дженерик или условный тип возврата. Дальше мы оставим параметры и посмотрим на скрытый первый параметр, который тайно есть у каждого метода: this, как TypeScript типизирует его фейковым параметром this: T и как он теряется, когда метод передают как колбэк. Теперь, когда вы видите, что перегрузка возвращает неожиданный тип для литерального аргумента, первым делом проверяйте порядок сигнатур — специфичная перегрузка могла оказаться под общей, которая поглощает её первой.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.