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

Generic-и в библиотеках: API, которые красиво выводятся (и те, что нет)

Проектирование generic-API, которые красиво выводятся: порядок параметров типа для потока вывода, ограничения и значения по умолчанию, блокировка вывода через `NoInfer`, возврат точного типа вместо ограничения. Отказ — API, требующий писать аргументы типа.

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

Две типизированные библиотеки event-emitter делают одно дело. С первой вы пишете emitter.emit("login", { userId: 1 }), и аргумент проверяется против нагрузки события "login" — без аргументов типа, полное автодополнение. Со второй приходится писать emitter.emit<"login", { userId: number }>("login", { userId: 1 }), повторяя то, что компилятор должен был знать, и одна опечатка рассинхронизирует две части. Та же фича — противоположная эргономика. Разница целиком в том, как generic-сигнатуры спроектированы для управления выводом. Авторы библиотек не просто пишут типы — они проектируют, где происходит вывод.

Цель: вызывающий пишет значения, компилятор пишет типы

Полярная звезда проектирования generic-API: вызывающий должен передавать обычные значения и получать точные типы без единого аргумента типа. Каждый <T>, который вызывающий вынужден написать, — провал дизайна: это тип, который API должен был вывести. Вывод происходит на местах вывода (inference sites): позиции в сигнатуре, где параметр типа стоит в типе параметра, позволяя компилятору считать его из аргумента.

// заставляет вызывающего указывать K — плохо
function getBad<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }
getBad<{ a: number }, "a">({ a: 1 }, "a"); // фу

// выводит оба из аргументов — хорошо
function get<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }
get({ a: 1, b: "x" }, "b"); // ^? string  — T и K оба выведены, без аргументов типа

Та же сигнатура, но второе место вызова поставляет значения в оба места вывода (obj фиксирует T, key фиксирует K), так что руками писать нечего. Возврат T[K] тогда точенstring, а не ограничение «keyof-что-то».

Возвращайте точный тип, никогда не ограничение

Классическая утечка: ограничить параметр и затем вернуть ограничение вместо выведенного типа. Вызывающий теряет всю специфичность, которую передал.

// возвращает ограничение — вызывающий получает обратно широкий тип
function firstWide<T extends unknown[]>(arr: T): unknown { return arr[0]; }
const a = firstWide([1, 2, 3]); // ^? unknown  — специфичность выброшена

// возвращает точный тип элемента через вывод
function first<T>(arr: readonly T[]): T | undefined { return arr[0]; }
const b = first([1, 2, 3]); // ^? number | undefined

Фикс — сделать T типом элемента (чтобы он выводился точно), а не ограничивать T всем массивом и возвращать расширенный кусок. Возврат ограничения — самый частый способ, которым библиотека «теряет типы».

Ограничения + значения по умолчанию для эргономики

Что если нужен generic, который выводит основной параметр типа из аргумента, но при этом даёт продвинутым вызывающим явно переопределить второстепенный? Именно этот пробел закрывают ограничения и значения по умолчанию.

Ограничения (T extends ...) держат вызывающих честными и открывают доступ к членам внутри тела; значения по умолчанию (T = ...) делают параметр опциональным, когда вывод не может его поставить (например, объект конфига, который никогда не передают). Вместе они позволяют одной сигнатуре обслуживать частый случай без церемоний и продвинутый случай с явным переопределением:

function createStore<State, Action = { type: string }>(initial: State) {
  return { state: initial } as { state: State; dispatch: (a: Action) => void };
}
const s = createStore({ count: 0 }); // State выведен {count:number}, Action по умолчанию

NoInfer: заблокировать нежелательное место вывода

Иногда параметр типа появляется в двух позициях, и вы хотите, чтобы вывод управляла только одна. До 5.4 нужен был хак с пересечением; TS 5.4 добавил встроенный NoInfer<T>:

// без NoInfer: TS расширяет C из ОБОИХ — массива и значения по умолчанию,
// так что опечатка в default всё равно «работает» через расширение объединения
function pick<C extends string>(choices: C[], fallback: C): C { /* ... */ return fallback; }
pick(["a", "b"], "c"); // C выведен как "a" | "b" | "c" — баг "c" принят!

// с NoInfer: место вывода только `choices`; fallback обязан совпасть с ним
function pickSafe<C extends string>(choices: C[], fallback: NoInfer<C>): C { return fallback; }
pickSafe(["a", "b"], "c"); // Error: "c" is not assignable to "a" | "b"

NoInfer<C> говорит компилятору «не считывай C с этого аргумента» — так что C фиксируется одним лишь choices, а fallback проверяется против него, а не расширяет его. Это точный инструмент для «этот параметр обязан соответствовать типу, уже определённому в другом месте».

Почему это работает

Почему версия без NoInfer тихо приняла баг? И choices, и fallback были местами вывода для C, так что TypeScript нашёл общий тип, удовлетворяющий обоим — объединение "a" | "b" | "c". Задача вывода — найти какой-нибудь тип, подходящий всем местам, а расширение до объединения подходит. NoInfer убирает место, чтобы оставшиеся пригвоздили тип, превращая тихое расширение в реальную ошибку.

Builder’ы, накапливающие типы

Текучие builder’ы (паттерн за zod, роутером tRPC, query builder’ами) протягивают эволюционирующий тип через каждый звено-вызов, возвращая новый generic-экземпляр:

class Query<Cols extends string = never> {
  select<C extends string>(col: C): Query<Cols | C> { return this as any; }
  build(): Cols[] { return [] as Cols[]; }
}
const cols = new Query().select("id").select("name").build();
// ^? ("id" | "name")[]  — каждый .select расширял накопленное объединение Cols

Каждый select возвращает Query<Cols | C>, так что тип растёт вдоль цепочки, и build() сообщает ровно выбранные колонки. Так query builder знает форму строки результата и так роутер tRPC (урок 3) накапливает свою карту процедур — каждый вызов .procedure(...) добавляет к типу роутера.

Викторина

Пользователь обязан вызывать ваш API как `parse<MySchema>(input)`, повторяя тип, который значение уже подразумевает. В чём проблема дизайна?

Расставь шаги по порядку

Расставьте шаги дизайна, чтобы get(obj, key) красиво выводился и возвращал точный тип:

  1. 1 Поставить параметры типа в позиции параметров, чтобы у каждого было место вывода (obj: T, key: K)
  2. 2 Ограничить где нужно для эргономики и безопасности (K extends keyof T)
  3. 3 Вернуть точный выведенный тип, а не ограничение (T[K], не unknown)
  4. 4 Использовать NoInfer на любом втором вхождении, которое надо проверять, а не использовать для вывода
  5. 5 Проверить на реальном месте вызова, что аргументы типа не нужны и тип результата точен
Вспомните перед уходом
  1. 01
    Что такое «место вывода» и почему вызывающий, вынужденный писать `<T>`, — провал дизайна?
  2. 02
    Почему «возврат ограничения вместо выведенного типа» — самый частый способ потерять типы, и каков фикс?
  3. 03
    Что делает NoInfer<T>, когда он нужен и как builder'ы накапливают типы?
Итог

Теперь вы можете спроектировать generic-API так, чтобы вызывающий писал значения, а компилятор — точные типы: ставьте параметры типа на места вывода, возвращайте выведенный тип, а не ограничение, используйте ограничения и значения по умолчанию для эргономики, блокируйте лишний вывод через NoInfer и накапливайте типы через цепочки builder’а. Это инструменты, которыми авторы библиотек делают get, zod и tRPC лёгкими в использовании. Обратная сторона — всё, что идёт не так в реальных кодовых базах: касты, утечки any, структурные сюрпризы, чрезмерно умные сигнатуры с нечитаемыми ошибками. Финальный урок — сводка senior-ловушек: для каждой — симптом, почему компилируется и фикс. Теперь, когда напишете generic и поймаете себя на слове «просто передай <ТвойТип> явно», это сигнал: у вас не хватает места вывода — и вы знаете, как его найти.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.