open atlas
↑ К треку
Система типов TypeScript вглубь TS · 03 · 04

Дженерик-классы

Параметр типа класса привязан к экземпляру, а не к статической стороне. Типизированные контейнеры вроде Stack<T> и Result<T, E> показывают почему — а структурная совместимость экземпляров предвещает вариантность.

TS Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Вы добавляете фабрику 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>, а errResult<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 используется внутри класса — позиции только-чтение ведут себя одним образом, позиции записи — другим. Этот вопрос направления присваиваемости — вариантность, и это весь следующий урок.

Во что дженерик-контейнеры обходятся файлам деклараций

Дженерики стираются из сгенерированного JSclass 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. 1 Вывести T экземпляра = number из аргумента конструктора 42
  2. 2 Типизировать экземпляр как Box<number>
  3. 3 Войти в map, чей параметр fn это (v: number) => U
  4. 4 Вывести собственный U метода map = string из возврата колбэка n.toFixed(2)
  5. 5 Сообщить результат как Box<string>
Закончи аналогию

Заполните пропуск: параметр типа класса привязан к _______, поэтому статическая сторона — общая для всех них — на него сослаться не может.

Вспомните перед уходом
  1. 01
    Почему статический член не может сослаться на параметр типа класса и каков стандартный фикс?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
опирается на

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.