Обобщённые функции и типы
Собеседник просит написать функцию, которая возвращает первый элемент массива — но массив может быть каким угодно: чисел, строк, объектов. «Как написать её один раз, чтобы TypeScript не терял тип результата?» Ответ — дженерики, и именно с этого вопроса часто начинается TypeScript-секция интервью.
Дженерик (обобщённый тип) — это тип-переменная: параметр, который позволяет написать функцию, класс или тип один раз, а конкретный тип данных подставить потом, при каждом использовании — и TypeScript сам отслеживает, какой именно тип подставился.
Вопрос-крючок
Вот та самая функция «взять первый элемент массива», написанная без дженериков — через <code>any</code>:
function firstAny(arr: any[]): any {
return arr[0];
}
const num = firstAny([10, 20, 30]);
const str = firstAny(["a", "b", "c"]);
console.log(num.toFixed(2)); // компилятор это пропустит...
console.log(str.toUpperCase()); // ...и это тоже, даже если типы перепутаныВывод:
10.00
AКод действительно работает и печатает то, что ожидалось. Но здесь есть подвох: TypeScript вообще не проверяет, что происходит с <code>num</code> и <code>str</code> после вызова <code>firstAny</code>. Из-за <code>any</code> компилятор молча пропустит и <code>num.toUpperCase()</code>, хотя <code>num</code> — число, у которого такого метода нет. Ошибка всплывёт только в браузере пользователя, а не в редакторе. Дженерики решают эту проблему полностью.
Почему any — это капитуляция, а не решение
<code>any</code> переводится как «здесь может быть что угодно, я разрешаю компилятору не проверять этот код». Это не типизация, а её отключение в одной конкретной точке — и отключение заразное: если <code>any</code> попадает в переменную, дальше по цепочке вызовов проверки типов тоже начинают молчать, пока кто-то явно не укажет тип заново.
Дженерик решает ту же задачу — «функция должна работать с разными типами» — но без потери проверки. Разница в одну букву <code>T</code> вместо <code>any</code>, а эффект принципиально другой:
function first<T>(arr: T[]): T {
return arr[0];
}
const num = first([10, 20, 30]); // T выводится как number
const str = first(["a", "b", "c"]); // T выводится как string
console.log(num.toFixed(2));
console.log(str.toUpperCase());
// console.log(num.toUpperCase()); // ошибка компиляции: у number нет toUpperCaseВывод:
10.00
AВнешне вызовы выглядят так же, как с <code>any</code>. Но если раскомментировать последнюю строку, компилятор сразу откажется собирать код: он точно знает, что <code>num</code> — это <code>number</code>, а не «что угодно», и метода <code>toUpperCase</code> у чисел не бывает. Ошибку поймали в редакторе, а не в проде.
Что означает <code>T</code> в угловых скобках
<code>T</code> здесь — не какое-то зарезервированное слово TypeScript, а обычное имя параметра типа, которое можно было назвать как угодно (по соглашению чаще всего берут одну заглавную букву: <code>T</code>, <code>U</code>, <code>K</code>, <code>V</code>). Смысл конструкции <code>function first<T>(arr: T[]): T</code> читается так: «эта функция принимает параметр типа T, и на время одного конкретного вызова T — это какой-то один зафиксированный тип, а не размытое <code>any</code>». Внутри тела функции T ведёт себя как настоящий тип: можно объявить переменную типа T, можно вернуть значение типа T, компилятор всё это проверит.
Дженерики бывают не только у функций — точно так же ими параметризуют классы, интерфейсы и псевдонимы типов:
interface Box<T> {
value: T;
}
const boxOfNumber: Box<number> = { value: 42 };
const boxOfString: Box<string> = { value: "привет" };
console.log(boxOfNumber.value, boxOfString.value);Вывод:
42 приветКак TypeScript выводит тип автоматически
В примерах выше нигде явно не написано <code>first<number>([10, 20, 30])</code> — компилятор сам понял, что <code>T</code> здесь <code>number</code>, просто взглянув на переданный аргумент. Это называется <strong>выводом типа</strong> (type inference): компилятор смотрит на реальные значения, которые передали в функцию, и подставляет вместо <code>T</code> наиболее подходящий тип — без единой явной аннотации от программиста.
Явное указание тоже возможно и иногда нужно — например, когда по аргументам вывести тип невозможно (пустой массив ничего не говорит о будущем содержимом):
function makeEmptyArray<T>(): T[] {
return [];
}
const numbers = makeEmptyArray<number>(); // T указали явно — вывести неоткуда
numbers.push(1);
numbers.push(2);
console.log(numbers);Вывод:
[ 1, 2 ]Здесь без явного <code><number></code> компилятор вывел бы <code>T</code> как <code>unknown</code> — ведь функция вызвана без аргументов, и подсказать тип неоткуда. Явная аннотация в угловых скобках при вызове — это способ сказать компилятору то, что он не может угадать сам.
Как это работает под капотом
Дженерики — это полностью механизм времени компиляции. Когда TypeScript видит вызов <code>first([10, 20, 30])</code>, он мысленно «подставляет» на место <code>T</code> тип <code>number</code> и проверяет весь код функции так, будто она с самого начала была написана именно под числа. При следующем вызове с массивом строк — та же самая проверка, но уже с <code>T</code> равным <code>string</code>. Никакого дублирования кода в рантайме не возникает — функция в скомпилированном JavaScript всего одна, обычная, без единого упоминания дженериков: угловые скобки, <code>T</code> и всё остальное полностью стираются при компиляции, потому что JavaScript вообще не знает, что такое типы.
Частые ошибки на собеседовании
- «Дженерики и any — это примерно одно и то же, просто дженерики выглядят посложнее». Это не так: <code>any</code> полностью отключает проверку типов, а дженерик сохраняет её, просто откладывая выбор конкретного типа до момента вызова. С дженериком компилятор всё ещё ловит несовместимые операции.
- Путают <code>T</code> как «любой тип без проверки» с реальным его смыслом. На самом деле T на каждый конкретный вызов фиксируется в один определённый тип, и весь код внутри функции должен быть корректен для любого возможного T — то есть для чисел и для строк одновременно, если специально не сузить T ограничением.
- Не понимают, зачем вообще может понадобиться явное указание типа в угловых скобках при вызове. Забывают, что вывод типа работает только тогда, когда компилятору есть на что посмотреть — если аргументов, из которых можно вывести T, попросту нет (как с пустым массивом), тип нужно указать явно.
- Считают, что дженерики существуют в скомпилированном JavaScript. На самом деле это чисто инструмент проверки типов на этапе разработки — в реальном JS-коде после сборки от <code><T></code> не остаётся ни следа.
Итоги
- Дженерик — параметр типа (обычно записывается как <code>T</code> в угловых скобках), который позволяет написать функцию, интерфейс или класс один раз для любых типов данных, не теряя проверку типов.
- В отличие от <code>any</code>, дженерик не отключает проверку — компилятор точно знает конкретный тип T на каждый вызов и не даст вызвать несовместимый метод.
- TypeScript чаще всего выводит T автоматически по переданным аргументам (type inference) — явно писать <code><number></code> при вызове почти никогда не нужно.
- Явное указание типа в угловых скобках нужно, когда вывести T по аргументам невозможно — например, при вызове без аргументов.
- Дженерики полностью исчезают при компиляции в JavaScript — это инструмент только для этапа проверки типов.