Обобщённые функции и типы

Собеседник просит написать функцию, которая возвращает первый элемент массива — но массив может быть каким угодно: чисел, строк, объектов. «Как написать её один раз, чтобы 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 — это инструмент только для этапа проверки типов.
Проверьте себя
1. Чем дженерик <T> принципиально отличается от any в сигнатуре функции?
AНичем, это просто два синтаксиса для одного и того же
BДженерик работает быстрее any в рантайме
CДженерик сохраняет проверку типов: конкретный T фиксируется на каждый вызов, а any отключает проверку полностью
DДженерики можно использовать только в классах, а any — только в функциях
2. Почему в вызове makeEmptyArray<number>() пришлось явно указать <number>, хотя обычно TypeScript выводит тип сам?
AЯвное указание типа обязательно всегда, вывод типа — это миф
BФункция вызвана без аргументов, а вывести T компилятору неоткуда, кроме как из переданных значений
CЭто ошибка в коде, явного указания там быть не должно
DmakeEmptyArray — зарезервированное имя, требующее аннотации