Глава 13. Функции как граждане первого класса
Глава 13. Функции как граждане первого класса
Перегрузка без второго тела
Вам нужна функция, которая по-разному работает с числом и со строкой. Рефлекс из любого статически типизированного языка подсказывает: перегрузить. Напишем два варианта, компилятор разведёт вызовы по типу аргумента.
function parseAmount(input: string): number;
function parseAmount(input: number): number;
// дальше ожидается второй заголовок и два разных тела...
И тут TypeScript требует странного. Он не даёт написать два тела. Он требует ровно одно — и одно общее на обе сигнатуры:
function parseAmount(input: string): number;
function parseAmount(input: number): number;
function parseAmount(input: string | number): number {
// единственное тело; ветвимся руками
return typeof input === "string" ? Number(input) : input;
}
Две верхние строки — это не объявления двух функций. Это две сигнатуры вызова (call signatures) над одной реализацией. Сама реализация — третья строка с телом — недоступна для вызова напрямую: снаружи видны только первые две. А внутри тела никакой диспетчеризации нет: вы сами проверяете typeof и сами ветвитесь. Компилятор не подставит «нужный метод» по типу аргумента, потому что метод один.
Скомпилируйте этот файл — и сигнатуры исчезнут. В рантайме останется единственная функция parseAmount с одним телом; обе шапки стёрты вместе со всеми остальными типами (Глава 3). То, что выглядело как перегрузка, не оставило в исполняемом коде ни следа от своей «перегруженности».
Дальше — ещё неуютнее. Колбэк, чей тип требует аргумент, спокойно принимает функцию, которая аргумент игнорирует:
function each(items: number[], fn: (x: number) => void): void {
for (const it of items) fn(it);
}
each([1, 2, 3], () => console.log("шаг")); // ок, хотя fn объявлен с аргументом
И метод, оторванный от объекта, теряет внутри себя this — причём молча, если не попросить компилятор следить:
const order = {
id: "A-1",
describe() { return `Заказ ${this.id}`; },
};
const f = order.describe;
f(); // в рантайме: this не тот; this.id — undefined или ошибка
Три наблюдения — перегрузки без второго тела, колбэк, проглатывающий функцию меньшей арности, метод, теряющий this, — кажутся тремя разными причудами. Они не разные. Все три вытекают из одного факта, который джавист сюда не приносит: в TypeScript функция — это значение со своим типом, а не метод, прикрученный к классу. Разберёмся, что это за тип, и три причуды сложатся в систему.
Функция — это значение со своим типом
Начнём с самого типа. У функции в TypeScript есть тип ровно в том же смысле, в каком он есть у числа или у строки. Его можно записать:
type Parse = (input: string) => number;
Читается как «функция, берущая string и возвращающая number». Это не имя класса, не интерфейс, который надо реализовать, не номинальная сущность — это описание формы: что на входе, что на выходе. Любая функция такой формы подходит под этот тип, и никакого «объявить, что я реализую Parse» не требуется.
Раз это тип значения, значение можно делать всё, что делают со значениями. Передавать в другую функцию:
function run(parse: Parse, raw: string): number {
return parse(raw);
}
Возвращать из функции:
function makeAdder(base: number): (x: number) => number {
return (x) => base + x; // замыкание над base
}
const addTen = makeAdder(10);
addTen(5); // 15
Хранить в переменной, складывать в массив, класть в поле объекта. Это и называется первоклассность (first-class): функция — полноправный гражданин, ходит везде, где ходят значения, без всяких привилегий и без всяких ограничений по сравнению с числом или объектом.
Теперь — формы параметров, потому что тип функции описывает не только «сколько и каких», но и «как именно» они передаются. Необязательный параметр помечается ?:
function describe(id: string, prefix?: string): string {
// prefix имеет тип string | undefined
return `${prefix ?? "Заказ"} ${id}`;
}
describe("A-1"); // ок
describe("A-1", "Order"); // ок
prefix?: string означает, что внутри тела его тип — string | undefined (union, объединение — тип «или одно, или другое»; при первом появлении поясняю, дальше — просто union). Параметр со значением по умолчанию делает то же самое снаружи, но избавляет от undefined внутри:
function describe2(id: string, prefix: string = "Заказ"): string {
// prefix здесь — просто string, undefined уже отсеян
return `${prefix} ${id}`;
}
Остаточный параметр (rest parameter) собирает хвост аргументов в массив:
function totalOf(...amounts: number[]): number {
return amounts.reduce((a, b) => a + b, 0);
}
totalOf(100, 200, 50); // 350
И последнее в этой секции — то, ради чего стоило начинать с типа. Раз у функции есть тип, компилятор умеет выводить типы параметров из контекста, в который функция попадает. Это контекстный вывод (contextual typing, отсылка к Главе 4): когда позиция уже знает, какой тип функции она ждёт, аннотировать параметры колбэка не нужно.
const amounts = [100, 200, 50];
amounts.map((x) => x * 2);
// ^ x выведен как number — из типа массива, а не объявлен
map ожидает в этой позиции функцию (value: number, ...) => U. Компилятор знает это до того, как посмотрит на ваше тело, и проливает тип внутрь: x становится number без единой аннотации. Аннотировать параметры колбэков в TypeScript — почти всегда лишний шум: тип уже течёт из контекста. Это прямое следствие того, что функция — значение со своим типом: тип известен по месту, а не по объявлению.
Кто берёт шире, тот и подставляется
Мы сказали, что любая функция нужной формы подходит под тип функции. Пора уточнить, что значит «нужной формы», потому что здесь интуиция обманывает почти всех — и обманывает в обе стороны.
Вернёмся к колбэку, который проглотил функцию без аргументов:
function each(items: number[], fn: (x: number) => void): void {
for (const it of items) fn(it);
}
each([1, 2, 3], () => console.log("шаг")); // принято
Почему функция нулевой арности годится туда, где ждут функцию с параметром? Подставьте себя на место each. Он будет звать fn(it), передавая число. Функция () => ... это число просто проигнорирует — и ничего не сломается. Лишний аргумент в JavaScript безвреден. Значит, функция, берущая меньше аргументов, безопасно встаёт на место функции, берущей больше: ей дадут лишнее, она его не заметит. А вот наоборот нельзя:
function each2(items: number[], fn: (x: number, i: number) => void): void {
for (let k = 0; k < items.length; k++) fn(items[k], k);
}
const needsOne: (x: number) => void = (x) => console.log(x);
each2([1, 2, 3], needsOne); // ок: needsOne просто не использует i
Функция, требующая больше, чем будут давать, — вот что было бы опасно, и такого компилятор не пропустит. Правило про арность: подставляется тот, кто берёт не больше параметров, чем будут передавать.
Теперь то же самое, но про типы параметров и возврата — и это сердце секции. Возьмём обработчик заказов. Есть общий Order и его частный случай — оплаченный заказ:
type Order = {
id: string;
total: number;
status: "created" | "paid" | "shipped";
};
type PaidOrder = Order & { status: "paid" };
type Handler = (o: PaidOrder) => void;
Вопрос: какую функцию можно положить в переменную типа Handler? Кандидат, который принимает строго PaidOrder, — очевидно да. А кандидат, который принимает любой Order?
const handlePaid: Handler = (o: PaidOrder) => console.log(o.status);
const handleAny: Handler = (o: Order) => console.log(o.id); // тоже ок!
Второй проходит — и это правильно. Handler обещает звать функцию, передавая ей PaidOrder. Функция, готовая обработать любой Order, тем более справится с PaidOrder: оплаченный заказ — это заказ. Кто умеет обрабатывать более широкий вход, годится туда, где дают более узкий. По параметрам присваиваемость идёт против наследования: тип функции принимает функцию с более широким параметром. Это называется контравариантность (contravariance) по параметрам.
А по возврату — наоборот. Тип, ожидающий функцию, которая вернёт Order, примет функцию, которая вернёт PaidOrder:
type Producer = () => Order;
const makePaid: Producer = (): PaidOrder => ({
id: "A-1", total: 500, status: "paid",
});
// ок: тот, кто ждёт Order на выходе, получит PaidOrder — это и есть Order
Кто возвращает уже — годится туда, где ждут шире. По возврату присваиваемость идёт по наследованию. Это ковариантность (covariance) по возврату.
Соберём в одну фразу, которую стоит запомнить: функция A подставляется вместо функции B, если A берёт шире и возвращает уже. Шире на входе, уже на выходе — такая функция справится со всем, что подсунут под видом B, и вернёт не больше обещанного.
Эта проверка по параметрам — состоятельна (sound), то есть не пропускает реально опасных подстановок, и включается она флагом strictFunctionTypes (Глава 5), который входит в strict. С ним функциональные позиции проверяются контравариантно — честно. Без него компилятор проверял бы параметры бивариантно, то есть в обе стороны, и пропускал бы дыры. К одной такой дыре — она осталась в языке даже при strict — мы вернёмся в «острых краях».
Перегрузки — это фикция проверяющего
Теперь, зная, что функция — это значение с типом, вернёмся к перегрузкам с другого конца. Что они вообще такое, если функция одна?
Перегрузка в TypeScript — это несколько сигнатур вызова, надетых на одну реализацию. Снаружи функция выглядит так, будто у неё несколько разных «лиц»; внутри она одна. Зачем это нужно — видно там, где union на возврате не выражает связь между входом и выходом.
Возьмём функцию, которая достаёт сущность по идентификатору. Если ей дать OrderId, она вернёт Order; если UserId — User. Попробуем без перегрузок, одним union:
type OrderId = string;
type UserId = string;
function fetch1(id: OrderId | UserId): Order | User {
/* ... */
}
const x = fetch1("A-1"); // тип x: Order | User — даже если мы дали OrderId
Тип результата — Order | User, всегда, при любом аргументе. Связь «дал заказ — получил заказ» потеряна: union на возврате знает только, что вернётся одно из двух, но не какое именно в зависимости от входа. Каждый вызов придётся сужать вручную. Перегрузка эту связь выражает:
function fetchEntity(id: OrderId): Order;
function fetchEntity(id: UserId): User;
function fetchEntity(id: string): Order | User {
// тело видит самый широкий случай и ветвится само
return id.startsWith("A-") ? loadOrder(id) : loadUser(id);
}
const ord = fetchEntity("A-1"); // если бы OrderId был branded — тип: Order
Снаружи две сигнатуры связывают конкретный вход с конкретным выходом. Внутри — одно тело, которое видит объединённый, самый широкий тип параметра (string) и само разбирается, что делать. Никто за него не диспетчеризует: ветвление на startsWith пишете вы.
И — то, ради чего эта секция стоит после главы о стирании: в рантайме перегрузок нет. Скомпилируйте — три шапки сложатся в одну функцию (Глава 3). Сигнатуры существовали только для проверяющей программы; исполняющая видит одно тело, одну функцию, один набор аргументов, которые она сама разбирает. Отсюда и название: перегрузки — это фикция проверяющего. Они меняют то, что компилятор думает о вызовах, и испаряются до запуска.
Когда перегрузка оправдана? Ровно в одном случае: когда тип возврата зависит от типа аргумента так, что union этой зависимости не выражает, — как fetchEntity выше. Во всех остальных случаях один параметр-union с честным сужением внутри (Глава 8) чище: он не разваливает функцию на призрачные «лица», не требует синхронизировать шапки с телом и читается как одно целое.
// чаще всего достаточно так — без перегрузок
function area(shape: { kind: "circle"; r: number } | { kind: "rect"; w: number; h: number }): number {
return shape.kind === "circle" ? Math.PI * shape.r ** 2 : shape.w * shape.h;
}
Вход — union, внутри сужение по kind (это discriminated union, размеченное объединение, — Глава 9), на выходе один тип. Перегрузка тут была бы лишней церемонией.
this как фиктивный параметр
Осталась третья причуда из начала главы — метод, теряющий this. Здесь TypeScript делает нечто изящное: типизирует this, которого в списке параметров нет.
В JavaScript this динамичен. Он не привязан к функции; он определяется тем, как функцию позвали. Позвали как order.describe() — this будет order. Оторвали и позвали как f() — this будет undefined (в strict-режиме JS) или глобальный объект. Функция одна, а this внутри неё — разный, и зависит от точки вызова, а не от точки определения.
TypeScript описывает ожидаемый this специальным первым параметром по имени this:
type Order = { id: string; total: number };
function describe(this: Order): string {
return `Заказ ${this.id} на сумму ${this.total}`;
}
this: Order — не настоящий параметр. Его не передают при вызове, он не попадает в arguments, он стирается вместе со всеми типами. Это фиктивный параметр, существующий только для того, чтобы проверяющий знал: эту функцию законно звать лишь там, где this имеет форму Order. Включённый noImplicitThis (часть strict, Глава 5) заставляет компилятор требовать такой аннотации везде, где this иначе оказался бы неявным any.
Польза видна, когда this теряют:
const order = { id: "A-1", total: 500 };
const detached = describe;
detached();
// ошибка: 'this' типа 'void' не присваивается 'this' типа 'Order'
Компилятор поймал отрыв метода ещё до запуска. Зов detached() не предоставляет this формы Order, и проверяющий это видит. Та же защита и для order.describe.call(...): подсунете не тот объект — получите ошибку типа.
Антипод — стрелочная функция. У неё нет своего this; она лексически захватывает this из места, где определена, раз и навсегда:
const counter = {
count: 0,
start() {
setInterval(() => {
this.count++; // this — это counter, потому что стрелка взяла его лексически
}, 1000);
},
};
Будь там обычная function () { this.count++ }, this внутри setInterval оказался бы не тем, и noImplicitThis это бы отметил. Стрелка снимает проблему, прибивая this к месту определения.
Но вот граница, которую важно увидеть ясно. Вся типизация this — гарантия только проверяющего. this: Order стирается; в рантайме ничто не мешает позвать функцию с неправильным this, если до неё дотянулись в обход компилятора — через any, через нетипизированный код, через динамический вызов. Безопасность this живёт в проверяющей программе и испаряется на границе рантайма, ровно как все остальные типы. Компилятор предупредит вас об отрыве метода, пока вы внутри его поля зрения; он бессилен, когда вы из этого поля вышли.
Глазами джависта
Здесь почти каждое слово главы противоречит вашему опыту, и стоит развести параллели по одной.
Перегрузка. В Java перегрузка — это несколько настоящих методов с одним именем и разными телами, и компилятор выбирает нужный по статическим типам аргументов, генерируя реальную диспетчеризацию. Два метода
parseAmount(String)иparseAmount(int)— это две разные точки в байт-коде. В TypeScript перегрузка — это несколько сигнатур над одним телом, и в рантайме сигнатуры стёрты, диспетчеризации нет, ветвление вы пишете руками внутри единственной реализации. Перенесёте сюда ожидание «компилятор разведёт вызовы по телам» — споткнётесь сразу: тел не несколько, тело одно.Функция-значение. В Java функция стала значением поздно и через узкую дверь: лямбда обязана соответствовать номинальному функциональному интерфейсу (
Function<T,R>,Runnable, ваш собственный@FunctionalInterface). Тип лямбды — это имя интерфейса, и совместимость номинальна. В TypeScript тип функции структурен:(x: number) => string— это форма, а не имя, и любая функция такой формы подходит без объявлений о намерениях. Никаких функциональных интерфейсов; сам тип функции и есть всё, что нужно.
this. В Javathisнамертво привязан к экземпляру; оторвать метод от объекта так, чтобыthisпотерялся, нельзя в принципе — ссылка на метод (order::describe) тащит получателя с собой. В JavaScriptthisопределяется точкой вызова и теряется на раз; типизацияthisв TS — это попытка вернуть проверяющему ту безопасность, которую JVM даёт по умолчанию, но только на время проверки и без рантайм-гарантии.Вариантность. Контравариантность параметров может показаться экзотикой, но компромисс с вариантностью функций вы уже видели — и в худшем варианте. Массивы в Java ковариантны:
Object[] a = new String[1]; a[0] = 42;компилируется и падает в рантайме сArrayStoreException. Java сознательно пустила несостоятельность в систему типов ради удобства, переложив проверку на рантайм. TypeScript приstrictFunctionTypesпроверяет параметры функций контравариантно и состоятельно — но ту же бивариантную дыру, что у джавовских массивов, оставил в одном месте: у параметров методов (не свойств-функций). Так что размен «удобство против состоятельности на функциональных позициях» вам знаком; различается лишь, где каждый язык провёл черту.
Где функции кусаются
Теперь честный разбор мест, где всё описанное ломается или подводит.
Бивариантная дыра в методах. Мы хвалили strictFunctionTypes за контравариантную, состоятельную проверку параметров. У него есть исключение, и оно коварно тем, что зависит от синтаксиса объявления. Параметры, объявленные через синтаксис метода, проверяются бивариантно — в обе стороны, то есть с дырой. Параметры, объявленные через свойство-функцию, — контравариантно, честно.
type Animal = { species: string };
type Dog = Animal & { breed: string };
// синтаксис метода — бивариантно, дыра пропускается:
type WithMethod = { handle(a: Dog): void };
const m: WithMethod = { handle(a: Animal) {} }; // ок (ожидаемо)
const m2: WithMethod = { handle(a: Dog & { tag: 1 }) {} }; // тоже ок — а это уже неверно
// синтаксис свойства-функции — контравариантно, честно:
type WithProp = { handle: (a: Dog) => void };
const p: WithProp = { handle: (a: Animal) => {} }; // ок
// const p2: WithProp = { handle: (a: Dog & { tag: 1 }) => {} }; // ошибка — и правильно
handle(a: Dog): void и handle: (a: Dog) => void описывают, казалось бы, одно и то же — но первое проверяется слабее. Дыра живёт здесь по историческим причинам: на ней держится присваиваемость вроде Array<Dog> к Array<Animal>, которой пользуются повсеместно. Запомнить просто: хотите строгую проверку функционального поля — пишите его как свойство-функцию, а не как метод.
Сигнатуру реализации звать нельзя. Тело перегрузки невидимо снаружи, даже если формально подходит:
function f(x: string): number;
function f(x: number): string;
function f(x: string | number): string | number { /* ... */ }
f(true); // ошибка: ни одна сигнатура не принимает boolean,
// хотя реализация объявлена как string | number
Видны только две верхние сигнатуры; string | number третьей строки — деталь реализации, не точка вызова. Это регулярно удивляет.
Рассинхрон шапок и тела. Связь между сигнатурами и реализацией компилятор проверяет лишь частично. Грубое расхождение он ловит: если тело объявить как function g(x: string), оно не покроет сигнатуру g(x: number), и компилятор отругается — TS2394: This overload signature is not compatible with its implementation signature. Но стоит дать реализации достаточно широкий тип параметра, чтобы формально все сигнатуры в него вписались, — и логику внутри тела никто уже не проверит:
function g(x: string): number;
function g(x: number): number;
function g(x: string | number): number {
// тело обслуживает только строку, про число забыли
return (x as string).length;
}
g(42); // проверку проходит — но в рантайме (42).length === undefined
Сигнатура реализации (string | number) совместима с обеими шапками, поэтому компилятор молчит, хотя тело честно обрабатывает лишь строку. Перегрузки требуют дисциплины: формальной совместимости сигнатур мало — за то, что тело реально обслуживает каждую шапку, отвечаете вы.
this стирается. Вся защита this — у проверяющего; в рантайме this: Order испарился, и неверный this, пришедший через any или динамику, никто не остановит. Сюда же — вечная путаница стрелки и function: стрелка не имеет своего this и игнорирует this-параметр, обычная функция берёт this из точки вызова. Перепутаете — и noImplicitThis поймает не всё.
«Меньше параметров — присваивается». Колбэк, проглатывающий аргументы, удобен, но иногда маскирует ошибку: вы хотели передавать обработчику индекс, обработчик его молча не берёт, и вы об этом не узнаете, потому что присваиваемость это разрешает. Это не баг — это та самая безопасность лишнего аргумента, но осознавать её стоит.
Контекстный вывод местами отказывает. Тип параметра колбэка течёт из контекста не всегда. Разорвите цепочку — вытащите колбэк в отдельную переменную без аннотации — и контекст пропадёт:
amounts.map((x) => x * 2); // x: number — контекст есть
const double = (x) => x * 2; // ошибка: 'x' неявно имеет тип 'any'
amounts.map(double); // контекст до объявления double не дотянулся
Пока функция-литерал стоит прямо в ожидающей позиции — вывод работает. Оторвёте — аннотируйте сами.
Остаточные параметры через кортежи. Тонкое, но полезное: rest-параметр можно типизировать не только массивом, но и кортежем (tuple), и тогда арность и типы фиксируются по позициям:
type Args = [id: string, total: number];
function make(...args: Args): Order {
const [id, total] = args;
return { id, total };
}
make("A-1", 500); // ровно два аргумента нужных типов
Это мост к типизации аргументов как данных — пригодится для оберток и каррирования, но новичка сбивает: ...args здесь не «сколько угодно», а строго два.
Функция — значение, а не метод на классе
Теперь то, что должно перещёлкнуть.
Перестаньте искать функцию при объекте и начните видеть в ней самостоятельное значение, у которого есть форма — входы и выход. Из этого вытекает несколько практических разворотов.
Предпочитайте одну функцию с union-параметром и сужением — перегрузкам. Перегрузка нужна ровно тогда, когда тип возврата зависит от типа аргумента так, что union на возврате эту зависимость теряет. Во всех остальных случаях один честный параметр-union и switch/typeof внутри читаются как одно целое и не требуют синхронизации призрачных шапок с реальным телом.
Уважайте вариантность как закон, а не как мелкий шрифт. Кто берёт шире и возвращает уже — тот и подставляется. Это не каприз компилятора: это то, что делает функцию безопасно заменяемой. Проектируя API, который принимает колбэк, объявляйте параметр колбэка настолько узким, насколько он вам реально нужен, — тогда вызывающий сможет дать функцию пошире, и всё сойдётся.
Типизируйте this, предпочитайте стрелки и привязку — но держите в голове, что это страховка проверяющего, а не рантайма. На границе, где значения приходят из нетипизированного мира, this так же беззащитен, как любой другой тип, и дисциплина на стыке остаётся вашей.
И самое общее: опирайтесь на первоклассные функции и композицию. Передать поведение значением, вернуть функцию из функции, собрать пайплайн из мелких функций — это направление, вдоль которого волокна языка лежат сами. Программа, построенная из функций-значений, идёт по волокнам TypeScript; программа, которая тащит сюда диспетчеризацию по перегруженным методам и this, прибитый к классу, идёт поперёк — и сопротивление, которое вы чувствуете, это сопротивление волокон, а не недоработка инструмента.
Итог
Функция в TypeScript — это типизированное значение со своей формой, а не метод, прикрученный к классу: отсюда контравариантность параметров, перегрузки как стираемая фикция проверяющего и this как фиктивный параметр. Кто мыслит функциями-значениями, вариантностью и сужением вместо перегрузок-диспетчеров, тот пишет по волокнам языка, а не поперёк.