Дженерик-классы
Параметр типа класса привязан к экземпляру, а не к статической стороне. Типизированные контейнеры вроде Stack<T> и Result<T, E> показывают почему — а структурная совместимость экземпляров предвещает вариантность.
Вы добавляете фабрику static empty(): Box<T> к классу Box<T>, и компилятор отказывает: Static members cannot reference class type parameters. Кажется произволом — класс ведь дженерик, почему его статический хелпер не может? Ответ раскрывает, чем именно является параметр типа класса и когда он привязывается к конкретному типу.
Класс, параметризованный типом
Зачем ставить параметр типа на класс, а не на каждый метод по отдельности? Потому что вы хотите, чтобы все методы экземпляра разделяли один и тот же разрешённый T — так Box<number> всегда работает только с числами, на каком бы методе вы ни остановились. Дженерик-класс объявляет параметр типа, который каждый экземпляр заполняет:
class Box<T> {
constructor(public value: T) {}
map<U>(fn: (v: T) => U): Box<U> {
return new Box(fn(this.value));
}
}
const b = new Box(42);
// ^? const b: Box<number> — T выведен из аргумента конструктора
const s = b.map((n) => n.toFixed(2));
// ^? const s: Box<string> — собственный U метода map выведен из колбэкаT решается из аргумента конструктора, ровно как дженерик-функция выводит из аргументов вызова. Метод map<U> вводит свой параметр типа U, независимый от T экземпляра.
Ограничение статической стороны
Каждый экземпляр привязывает T к конкретному типу. Но статические члены принадлежат объекту класса, который существует один раз, до и поверх всех экземпляров — у них нет единого T, на который сослаться:
class Box<T> {
constructor(public value: T) {}
// Error: Static members cannot reference class type parameters.
static empty(): Box<T> {
return new Box(undefined as any);
}
}Фикс — сделать сам статический член дженериком: со своим параметром типа, разрешаемым в точке статического вызова:
class Box<T> {
constructor(public value: T) {}
static of<U>(value: U): Box<U> {
return new Box(value);
}
}
const b = Box.of("hi");
// ^? const b: Box<string>Box.of<U> не имеет отношения к T какого-либо экземпляра; он решается в точке статического вызова. Поэтому фабричные методы пишут как самостоятельные дженерики.
Реальный контейнер: Result<T, E>
Два параметра типа моделируют успех-или-неудачу без исключений:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
function parsePort(s: string): Result<number, string> {
const n = Number(s);
return Number.isInteger(n) && n > 0 ? ok(n) : err(`bad port: ${s}`);
}
const r = parsePort("8080");
if (r.ok) {
r.value;
// ^? (property) value: number — сужено дискриминантом
} else {
r.error;
// ^? (property) error: string
}ok возвращает Result<T, never>, а err — Result<never, E>, так что неиспользуемая сторона это never — присваиваемый любому ожидаемому типу ошибки или значения. Дискриминант ok затем сужает объединение (это был урок про размеченные объединения) до полезной нагрузки ровно одной ветви.
Дженерик-интерфейсы для фабрик
Интерфейсы тоже несут параметры типа — так типизируют фабрику или контракт коллекции независимо от реализации:
interface Repository<T, Id = string> {
find(id: Id): T | undefined;
save(entity: T): void;
}
interface Comparator<T> {
(a: T, b: T): number;
}
const byLength: Comparator<string> = (a, b) => a.length - b.length;
// ^? const byLength: Comparator<string>Repository<T, Id = string> показывает дефолты на интерфейсах (большинство id — строки); Comparator<T> — дженерик сигнатура вызова, тип функции, параметризованный T.
Структурная совместимость предвещает вариантность
Два дженерик-экземпляра совместимы, когда совместимо их содержимое — TypeScript структурен, поэтому Box<T> это просто «объект со свойством value: T»:
class Box<T> {
constructor(public value: T) {}
}
let named: Box<{ name: string }> = new Box({ name: "Rex" });
let dogBox: Box<{ name: string; legs: number }> = new Box({ name: "Rex", legs: 4 });
named = dogBox; // ок — Box<{name; legs}> присваиваем Box<{name}>
// dogBox = named; // Error: отсутствует 'legs' — цель уже, источник ширеПрисваиваем ли Box<Dog> к Box<Animal>, зависит от того, как T используется внутри класса — позиции только-чтение ведут себя одним образом, позиции записи — другим. Этот вопрос направления присваиваемости — вариантность, и это весь следующий урок.
Во что дженерик-контейнеры обходятся файлам деклараций
Дженерики стираются из сгенерированного JS — class Box<T> компилируется в обычный class Box без следа T в рантайме. Но они вполне присутствуют в декларациях. Недженериковый контейнер выдаёт плоскую запись в .d.ts; глубоко параметризованный — Result<T, E>, Repository<T, Id>, билдер, протаскивающий три параметра типа через десяток методов — выдаёт каждую сигнатуру метода в полной дженериковой форме, и авторы библиотек регулярно наблюдают, как .d.ts публичного API растёт с нескольких килобайт до десятков, стоит типам контейнеров стать богатыми. Этот бандл уезжает каждому потребителю, и tsc каждого потребителя его перепроверяет — так что переусложнённые публичные типы это налог, который платят все ниже по потоку, а не только автор.
Есть и аспект задержки в редакторе. Каждый new Box(...) и каждый вызов .map(...) заново прогоняет вывод для T этого экземпляра (и собственного U метода); экран сцепленных операций над контейнером — это экран независимых задач вывода, которые языковой сервер решает на каждое нажатие. Один Result<T, E> бесплатен; горячий путь из двадцати сложенных вызовов дженерик-контейнера — это там, где hover и автодополнение начинают тормозить, а Check time в --extendedDiagnostics для этого файла растёт непропорционально числу строк.
▸Почему это работает
Почему ограничение статики — не просто недостающая фича? В рантайме существует ровно одна функция-конструктор Box. Дженерики стираются — они есть только для проверяющего. T экземпляра — это фикция, которую проверяющий поддерживает по-экземплярно из вызова конструктора; но статический метод общий для всех экземпляров независимо от их T, поэтому нет согласованного значения, которое T мог бы принять в статической области. Сделав статический метод дженериком, восстанавливаем согласованность: он получает свежий параметр типа, решаемый в собственной точке вызова, не связанный ни с одним экземпляром.
Компромисс, который сеньор взвешивает с дженерик-контейнером, — переиспользование против веса деклараций и стоимости вывода, которые он навязывает каждому потребителю. Result<T, E> или Stack<T>, действительно несущий свой тип-нагрузку насквозь, оправдывает эти издержки — он убирает приведения на каждой точке вызова. Но контейнер, параметризованный «на всякий случай» — третья переменная типа, которую ни один метод не связывает, или Repository<T, Id, Meta>, где Meta трогается в одном месте — платит полную цену .d.ts и вывода, не давая никакой безопасности. Дисциплина зеркалит правило для функций из первого урока: параметр типа класса должен связывать позиции по всей поверхности контейнера; в момент, когда один из них декоративен, уберите его и дайте контейнеру быть конкретным — потому что здесь цену несёт каждый, кто его импортирует.
Почему `static empty(): Box<T> { ... }` внутри `class Box<T>` не компилируется?
В `function ok<T>(value: T): Result<T, never>` почему слот ошибки типизирован как `never`?
Расставьте, как проверяющий типизирует `new Box(42).map(n => n.toFixed(2))` для `class Box<T> { constructor(public value: T) {} map<U>(fn: (v: T) => U): Box<U> }`:
- 1 Вывести T экземпляра = number из аргумента конструктора 42
- 2 Типизировать экземпляр как Box<number>
- 3 Войти в map, чей параметр fn это (v: number) => U
- 4 Вывести собственный U метода map = string из возврата колбэка n.toFixed(2)
- 5 Сообщить результат как Box<string>
Заполните пропуск: параметр типа класса привязан к _______, поэтому статическая сторона — общая для всех них — на него сослаться не может.
- 01Почему статический член не может сослаться на параметр типа класса и каков стандартный фикс?
- 02Объясните, как `Result<T, E>` плюс конструкторы `ok`/`err` дают обработку ошибок без исключений, включая роль `never`.
Дженерик-классы и интерфейсы параметризуют экземпляры типом, который даёт конструктор (или фабрика), а методы могут добавлять свои независимые параметры типа. Статическая сторона не может сослаться на параметр типа класса — статика существует до того, как любой экземпляр его привяжет, — поэтому фабрики пишут как самостоятельные дженерики. Типизированные контейнеры вроде Stack<T> и Result<T, E> — повседневная выгода, а дженерик-интерфейсы с дефолтами моделируют репозитории и компараторы. Может ли Box<Dog> подменить Box<Animal>, зависит от того, как T используется внутри класса; этот вопрос направления присваиваемости — вариантность, тема и реальная опасность следующего, заключительного урока этого юнита. Теперь, когда увидишь Static members cannot reference class type parameters, — ответ мгновенный: дай статике собственный параметр типа, разрешаемый в её же точке вызова.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.