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

Дефолты и вывод

Дефолтные параметры типа заполняют хвост, когда выводить нечего, но вывод всегда побеждает, где может. `NoInfer<T>` (TS 5.4) блокирует вывод в позиции, чтобы один аргумент задавал T для остальных.

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

Сигнатура 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. 1 Если вызывающий написал явный аргумент типа для T — использовать его
  2. 2 Иначе попытаться вывести T из аргументов вызова, упоминающих его
  3. 3 Применить ограничение как верхнюю границу для проверки выбранного T
  4. 4 Только если нет ни явного аргумента, ни вывода — откатиться к дефолту
  5. 5 Если ничто из этого не дало типа, неограниченный T разрешается в unknown
Закончи аналогию

Заполните пропуск: между выводом и дефолтом всегда побеждает _______; дефолт — лишь запасной вариант на случай, когда выводить нечего.

Вспомните перед уходом
  1. 01
    Сформулируйте приоритет между явными аргументами типа, выводом и дефолтами и объясните правило «всё-или-ничего» для явных аргументов.
  2. 02
    Опишите баг, который чинит NoInfer, на примере `createState(initial, allowed)`.
Итог

Дефолтные параметры типа — это запасные варианты, а не переопределения: проверяющий предпочитает явный аргумент типа, затем вывод, и доходит до дефолта, лишь когда оба отсутствуют — поэтому дефолт на параметре, который должен был выводиться, тихо маскирует провал. Дефолты заполняют хвостовые параметры, а правило «всё-или-ничего» управляет ведущими явными аргументами. Сочетание ограничения с дефолтом даёт эргономичные библиотечные дженерики, а NoInfer<T> (TS 5.4) позволяет исключить позицию из вывода, чтобы один аргумент задавал T, а остальные лишь проверялись. Дальше мы перенесём дженерики в классы и интерфейсы, где параметры типа уровня экземпляра и ограничение статической стороны вводят новое семейство ловушек. Теперь, когда что-то идёт не так ниже по потоку и тип оказывается AnyAction вместо ожидаемого — первый вопрос: был ли здесь дефолт, замаскировавший несостоявшийся вывод?

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.