Дефолты и вывод
Дефолтные параметры типа заполняют хвост, когда выводить нечего, но вывод всегда побеждает, где может. `NoInfer<T>` (TS 5.4) блокирует вывод в позиции, чтобы один аргумент задавал T для остальных.
Сигнатура createStore<State, Action = AnyAction> благополучно выходит в релиз. Потом кто-то вызывает createStore(reducer) и получает запутанную ошибку про Action, а другой вызывает createStore<MyState>(reducer), и тип действия молча становится AnyAction вместо вывода из reducer. Дефолт сделал своё дело — и именно в этом проблема: он замаскировал вывод, который должен был произойти.
Что делает дефолт
Зачем дефолт вообще нужен? Потому что иногда в вызове нет ничего, из чего можно вывести тип, — а заставлять пользователя писать аргумент типа каждый раз делает API неудобным. Дефолтный параметр типа задаёт запасной тип, используемый, когда проверяющий не может ни вывести, ни получить аргумент типа явно:
function emptyArray<T = string>(): T[] {
return [];
}
const a = emptyArray();
// ^? const a: string[] — сработал дефолт (выводить нечего)
const b = emptyArray<number>();
// ^? const b: number[] — явный аргумент переопределяет дефолтДефолты похожи на дефолты параметров-значений, но живут в пространстве типов. Полезнее всего они на типах/классах, где выводить действительно не из чего (подробнее о классах — следующий урок).
Вывод побеждает дефолт
Первое правило: вывод всегда побеждает. Дефолт заполняет только тогда, когда для параметра нет никакой информации.
function box<T = string>(value: T): { value: T } {
return { value };
}
const x = box(42);
// ^? const x: { value: number } — выведено из аргумента, НЕ string
const y = box();
// Error: Expected 1 arguments, but got 0. (value обязателен)T выводится как number из аргумента; дефолт = string нерелевантен, потому что вывод удался. Дефолт никогда не переопределяет пригодный вывод — он лишь подстраховывает его отсутствие.
Частичные явные аргументы: всё-или-ничего, потом дефолты заполняют хвост
Явные аргументы типа позиционны и всё-или-ничего для ведущих параметров: нельзя задать второй, выводя первый. Но дефолты позволяют остановиться раньше — как только у параметра есть дефолт, можно опустить его и всё, что после.
function make<A, B = A>(a: A, b: B): [A, B] {
return [a, b];
}
const m = make("x", 9);
// ^? const m: [string, number] — оба выведены
const n = make<string>("x", 9);
// Не ошибка: B имеет дефолт (= A), поэтому один явный аргумент допустим.
// ^? const n: [string, number] — A явный, B всё ещё выведен в numberПравило: можно опустить хвостовые аргументы типа, имеющие дефолты, но нельзя пропустить ведущий, чтобы задать более поздний. Дефолты заполняют хвост; вывод всё ещё может уточнить параметр с дефолтом, если значение его задаёт.
Эргономичные дженерики: ограничение + дефолт вместе
Самые удобные библиотечные дженерики сочетают ограничение (требование) с дефолтом (частый случай):
type EventMap = Record<string, unknown>;
class Emitter<Events extends EventMap = Record<string, never>> {
emit<K extends keyof Events>(key: K, payload: Events[K]): void {
// ...
}
}
// Частый случай: события не объявлены — дефолт заставляет emit(...) отклонять любой ключ.
const bare = new Emitter();
// Богатый случай: объявить карту и получить полностью типизированный emit.
const typed = new Emitter<{ login: { userId: string } }>();
typed.emit("login", { userId: "u1" }); // ок
// @ts-expect-error "logout" не является ключом объявленных событий
typed.emit("logout", { userId: "u1" });extends EventMap требует, чтобы переданное было картой событий; = Record<string, never> делает разумным сценарий без конфигурации.
NoInfer: блокировка вывода в позиции (TS 5.4)
Иногда нужно, чтобы один аргумент задавал T, а остальные лишь проверялись против него — не участвуя в выводе T. До TS 5.4 это протекало:
// Один дефолт, значение должно быть одной из допустимых строк.
function createState<T>(initial: T, allowed: T[]): T {
return initial;
}
// БАГ (до NoInfer): T выводится из ОБОИХ аргументов, поэтому T расширяется.
const s = createState("seattle", ["seattle", "denver"]);
// ^? const s: string — тут ладно, но защита от опечатки исчезла:
createState("seatle", ["seattle", "denver"]); // нет ошибки! T = stringПоскольку allowed: T[] тоже питает вывод, опечатка в initial просто расширяет T до string, и гарантия «должно быть в списке» испаряется. NoInfer<T> велит проверяющему не выводить T из этой позиции:
function createState<T>(initial: T, allowed: NoInfer<T>[]): T {
return initial;
}
const ok = createState("seattle", ["seattle", "denver"]);
// ^? const ok: "seattle" — T задан одним лишь `initial`
createState("seatle", ["seattle", "denver"]);
// T пиннится к литералу "seatle" из `initial`; массив теперь должен быть
// "seatle"[], поэтому каждый реальный элемент отвергается:
// Error: Type '"seattle"' is not assignable to type '"seatle"'.Теперь T выводится исключительно из initial как литерал "seatle", а allowed проверяется против T, а не питает его — поэтому несовпадающие элементы массива сигналят об опечатке. (Исправьте опечатку на "seattle", и T станет "seattle", массив совпадёт, и код скомпилируется.)
Числа за замаскированным выводом
Цена дефолта, который замазывает вывод, не абстрактна — она считается точками вызова, которым приходится компенсировать. Представьте createStore<State, Action = AnyAction>, экспортируемый из общего пакета и вызываемый, скажем, в 70 местах по приложению. Дефолт Action = AnyAction означает, что ни один из этих вызовов не падает, когда тип действия не утекает из reducer; вместо этого каждый молча разрешает Action в AnyAction, и поломка всплывает ниже по потоку как any-типизированные действия в reducer’ах и middleware. Когда команда наконец ужесточает тип (убирает дефолт или чинит вывод), каждая точка вызова, опиравшаяся на запасной вариант, теперь требует внимания — обычно это десятки добавленных вручную явных аннотаций createStore<MyState, MyAction>(...), ровно та работа, которую дефолт прятал. Это миграционный налог удобного дефолта: уплачивается поздно, разом, по всему графу вызовов, а не в одной сигнатуре.
NoInfer<T> появился в TypeScript 5.4 (март 2024); до этого тот же эффект требовал рукодельных трюков вроде пересечения с фантомным типом или второго параметра типа — каждый добавлял инстанцирования и затемнял сигнатуру. На 5.4+ это одна обёртка без рантайм-стоимости и без лишнего параметра типа — самое дешёвое из доступных решений для «один аргумент должен решать T, остальные проверяются». Если ваш tsconfig целит в более старый TypeScript, его у вас просто нет — что само по себе повод держать тулчейн актуальным.
▸Почему это работает
Почему «полезный» дефолт опасен? Дефолт делает параметр опциональным в глазах вывода. Если поставить дефолт на параметр, который должен был выводиться из значения — вроде Action = AnyAction на хранилище, чьи действия полностью выводимы из reducer, — то вызов, не сумевший вывести, не выдаст ошибку; он молча разрешится в дефолт. Баг всплывёт далеко: AnyAction потечёт туда, где ждали точное объединение. Тянитесь за дефолтом, только когда выводить действительно нечего (часто это класс без аргумента конструктора, несущего тип), а не как прикрытие вывода, который не вышло наладить.
Компромисс — эргономика сейчас против тихого режима отказа потом. Дёфолт действительно сглаживает zero-config-случай: new Emitter() без событий приятно писать. Но то же удобство превращает потенциальную ошибку компиляции в тихое расширение до запасного типа, и вопрос сеньора всегда: «если вывод здесь провалился, я хочу громкую ошибку или тихий AnyAction?» Когда вы хотите громкую ошибку — опустите дефолт и дайте отсутствующему выводу всплыть; когда запасной вариант и правда верный ответ для случая без информации — оставьте его, но потянитесь за NoInfer на любом параметре, который должен проверяться против T, а не питать его. Дефолты — для подлинных слотов без информации; никогда — как замена выводу, который не вышло наладить.
Для `function box<T = string>(value: T): { value: T }` какой тип у `box(42)`?
В `createState<T>(initial: T, allowed: NoInfer<T>[])` зачем оборачивать второй параметр в `NoInfer`?
Расставьте приоритет, который проверяющий использует для разрешения одного параметра типа T:
- 1 Если вызывающий написал явный аргумент типа для T — использовать его
- 2 Иначе попытаться вывести T из аргументов вызова, упоминающих его
- 3 Применить ограничение как верхнюю границу для проверки выбранного T
- 4 Только если нет ни явного аргумента, ни вывода — откатиться к дефолту
- 5 Если ничто из этого не дало типа, неограниченный T разрешается в unknown
Заполните пропуск: между выводом и дефолтом всегда побеждает _______; дефолт — лишь запасной вариант на случай, когда выводить нечего.
- 01Сформулируйте приоритет между явными аргументами типа, выводом и дефолтами и объясните правило «всё-или-ничего» для явных аргументов.
- 02Опишите баг, который чинит NoInfer, на примере `createState(initial, allowed)`.
Дефолтные параметры типа — это запасные варианты, а не переопределения: проверяющий предпочитает явный аргумент типа, затем вывод, и доходит до дефолта, лишь когда оба отсутствуют — поэтому дефолт на параметре, который должен был выводиться, тихо маскирует провал. Дефолты заполняют хвостовые параметры, а правило «всё-или-ничего» управляет ведущими явными аргументами. Сочетание ограничения с дефолтом даёт эргономичные библиотечные дженерики, а NoInfer<T> (TS 5.4) позволяет исключить позицию из вывода, чтобы один аргумент задавал T, а остальные лишь проверялись. Дальше мы перенесём дженерики в классы и интерфейсы, где параметры типа уровня экземпляра и ограничение статической стороны вводят новое семейство ловушек. Теперь, когда что-то идёт не так ниже по потоку и тип оказывается AnyAction вместо ожидаемого — первый вопрос: был ли здесь дефолт, замаскировавший несостоявшийся вывод?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.