Интуиция вариантности
Вариантность — это направление, в котором подтипизация течёт сквозь конструктор типа. Параметры функций контравариантны, массивы несоундно ковариантны, а параметры методов остаются бивариантными даже под strictFunctionTypes — ловушка, пропускающая слишком узкий колбэк.
Реестр принимает (handler: (e: Event) => void). Вы передаёте (e: MouseEvent) => void, потому что обработчик срабатывает лишь на кликах — и это проходит проверку. Потом приходит не-мышиный Event, обработчик читает e.button и получает undefined в рантайме без предупреждения компилятора. Дыра — это вариантность, а причина, по которой она проскользнула, — дефолт TypeScript, о котором большинство инженеров и не подозревают, что включили.
Три отношения, один вопрос
Пусть Dog extends Animal (каждая Dog — это Animal). Вариантность спрашивает: для конструктора типа F<_> как F<Dog> относится к F<Animal>?
- Ковариантно —
F<Dog>присваиваемF<Animal>(подтипизация течёт в ту же сторону). Производители только-чтение. - Контравариантно —
F<Animal>присваиваемF<Dog>(подтипизация течёт в обратную сторону). Потребители входа. - Инвариантно — ни в одну сторону; только точные совпадения.
- Бивариантно — принимаются обе стороны (несоундно; TS делает это намеренно в одном месте).
Вместе эти четыре случая покрывают любую позицию, которую может занимать переменная типа: производит ли конструктор типа, потребляет или делает и то и другое — это и определяет, какое направление безопасно. Следующие три раздела покажут каждый случай на реальном TypeScript, включая тот, где TS сознательно выбирает несоундный вариант.
Почему параметры функций контравариантны
Функция, принимающая Animal, может подменить функцию, принимающую Dog, — потому что всё, что передаёт ей Dog, передаёт корректный Animal. Обратное небезопасно:
type Animal = { name: string };
type Dog = { name: string; bark(): void };
const feedAnimal = (a: Animal) => a.name;
let feedDog: (d: Dog) => string;
feedDog = feedAnimal; // ок — Dog ЕСТЬ Animal, feedAnimal с ним справится
// небезопасное направление:
let feedAnimal2: (a: Animal) => string;
const handleDog = (d: Dog) => d.bark();
// feedAnimal2 = handleDog;
// Error (под strictFunctionTypes): handleDog нужен bark(), но его могут
// вызвать с обычным Animal без bark.Принимать более широкий вход безопасно; принимать вход уже, чем обещает слот, — это дыра: вызов с более широким значением, которое слот допускает, коснулся бы отсутствующих членов. Поэтому параметры самостоятельных функций контравариантны.
Почему массивы (несоундно) ковариантны
TypeScript считает Array<Dog> присваиваемым Array<Animal>, хотя массивы изменяемы — намеренная, известная несоундность ради эргономики:
const dogs: Dog[] = [{ name: "Rex", bark: () => {} }];
const animals: Animal[] = dogs; // разрешено — ковариантные массивы
animals.push({ name: "Whiskers" }); // кладём не-Dog в Dog[]!
dogs[1].bark();
// Рантайм: bark is not a function — система типов нас не остановила.Соундная система сделала бы позицию изменяемого элемента инвариантной. TS выбирает ковариантность, потому что преобладает чтение из массивов, а полная инвариантность была бы мучительна. Знать о существовании этой дыры — сеньорский вывод.
Ловушка бивариантных методов
Теперь баг из хука. strictFunctionTypes (TS 2.6) делает параметры самостоятельных типов-функций контравариантными — но по замыслу это не распространяется на параметры методов, которые остаются бивариантными. Одинаково выглядящие объявления ведут себя по-разному:
// tsconfig: { "strictFunctionTypes": true }
type Handler = (e: Event) => void;
// Синтаксис МЕТОДА — параметр БИВАРИАНТЕН (ловушка):
interface BusMethod {
on(handler: (e: Event) => void): void;
}
// Синтаксис СВОЙСТВА — параметр КОНТРАВАРИАНТЕН (проверяется):
interface BusProp {
on: (handler: (e: Event) => void) => void;
}
declare const m: BusMethod;
declare const p: BusProp;
const narrow = (e: MouseEvent) => e.button;
m.on(narrow); // ✅ НЕТ ОШИБКИ — параметр метода бивариантен (несоундно!)
p.on(narrow);
// ❌ Error: '(e: MouseEvent) => number' не присваиваем '(e: Event) => void'
// Типы параметров 'e' несовместимы:
// в 'Event' отсутствуют свойства 'MouseEvent' (button, ...).Единственное отличие — сокращение метода (on(...)) против свойства, хранящего функцию (on: (...) => ...). Форма метода пропускает слишком узкий обработчик MouseEvent, поэтому при диспетчеризации обычного Event поле e.button оказывается undefined. Поэтому методы Array.prototype и многие DOM/event API принимают колбэки, технически слишком узкие, — бивариантность вшита в форму объявления метода.
Аннотации вариантности: in / out (TS 4.7)
TS 4.7 позволяет заявить намеренную вариантность параметра типа через in (контравариантно) и out (ковариантно). Проверяющий затем сверяет, что ваше использование соответствует — а ещё аннотация документирует намерение и может ускорить проверку:
interface Producer<out T> { // T встречается только в позициях вывода
get(): T;
}
interface Consumer<in T> { // T встречается только в позициях ввода
set(value: T): void;
}
interface Box<in out T> { // T и читается, и пишется → инвариантно
get(): T;
set(value: T): void;
}
// Если объявить `out`, но затем написать T во входной позиции, получите:
interface Bad<out T> {
set(value: T): void;
// Error: тип 'T' объявлен ковариантным ('out'), но встречается
// в контравариантной позиции. (примерно)
}Аннотации не меняют правила присваиваемости, которые проверяющий уже выводит; они позволяют утвердить и навязать намеренную вариантность, ловя ошибку, например, добавления сеттера к типу, который вы обещали как чистого производителя.
Числа и версии за этими дырами
Каждое из этих поведений привязано к конкретному релизу, и даты важны, когда читаешь tsconfig или гоняешься за вопросом «почему это вообще когда-то проходило проверку». Бивариантные параметры методов и несоундная ковариантность массивов предшествуют strict-режиму целиком. strictFunctionTypes появился в TS 2.6 (окт 2017) и ужесточил отдельные параметры функциональных типов до контравариантных — но намеренно оставил параметры метод-сокращений и конструкторов бивариантными, и это ровно та дыра, которой пользуется зачин. Аннотации вариантности in/out появились в TS 4.7 (май 2022). Так что баг с обработчиком, проскользнувший через слот-метод, — не дефект компилятора для багрепорта, а девятилетний задокументированный компромисс, и "strictFunctionTypes": false (или отсутствие strict: true) молча расширяет дыру на все параметры функций.
У аннотаций есть и измеренный выигрыш в производительности — поэтому крупные кодовые базы внедряют их сверх документационной ценности. Вывод вариантности для глубоко рекурсивного дженерик-типа — работа, которую проверяющий повторяет; указание out T / in T позволяет ему пропустить этот вывод и использовать вашу декларацию напрямую. Команда TS сообщала, что аннотации вариантности и связанные изменения кэширования заметно сократили время структурного сравнения на тяжёлых дженерик-типах — порядка десятков процентов времени проверки на худших рекурсивных типах — а это разница между отзывчивым редактором и тормозящим на большой схеме или глубоко дженериковом типе ORM. Аннотация, таким образом, — три вещи сразу: документация, страховка корректности против неверно объявленного производителя/потребителя и подсказка проверяющему, способная отыграть время проверки.
▸Почему это работает
Почему TS вообще выбрал несоундную ковариантность массивов и бивариантные методы? Оба предшествуют strictFunctionTypes. Бивариантность методов конкретно сохраняет эргономику Array<T>, Promise<T> и event-подобных API: arr.forEach((x: Dog) => ...) на более широком массиве или addEventListener("click", (e: MouseEvent) => ...) иначе постоянно бы ошибались. Цена — реальные дыры. Сеньорский ход: (1) предпочитать форму объявления свойства-функции, когда хотите, чтобы параметр действительно проверялся, и (2) знать, что колбэк, принятый методом, может быть уже объявленной сигнатуры, поэтому никогда не читайте у параметра колбэка поля, которые объявленный (более широкий) тип не гарантирует.
Компромисс, который сделал TypeScript — и который вы наследуете, — это соундность, обменянная на эргономику, и сеньор принимает его осознанно, а не борется с ним. Требовать полной соундности здесь значило бы инвариантные массивы (постоянные приведения в read-heavy-коде) и контравариантные параметры методов (каждый addEventListener("click", (e: MouseEvent) => …) и arr.forEach((x: Dog) => …) падал бы), поэтому язык выбрал дыры. Принимая их, вы отдаёте compile-time-гарантию ровно в двух местах; покупаете — API, которые с вами не воюют. Дисциплина не в том, чтобы устранить несоундность, а в том, чтобы огородить её: используйте форму свойства-функции там, где вариантность колбэка реально важна, никогда не доверяйте узкому типу параметра колбэка сверх того, что гарантирует объявленный тип, и тянитесь за in/out на собственных дженериках, чтобы намеренная вариантность навязывалась, а не оставлялась выводу.
Пусть `Dog extends Animal`. Какое присваивание соундно для самостоятельных типов-функций под strictFunctionTypes?
Почему `m.on((e: MouseEvent) => ...)` проходит проверку, а `p.on((e: MouseEvent) => ...)` ошибается, хотя оба `on` ждут `(e: Event) => void`?
Расставьте рассуждение, показывающее несоундность ковариантности изменяемых массивов (при Dog extends Animal):
- 1 TS позволяет присвоить Dog[] переменной типа Animal[] (ковариантные массивы)
- 2 Через алиас Animal[] вызвать push с обычным Animal, который не Dog
- 3 Нижележащий массив — всё ещё Dog[] в другом месте — теперь содержит не-Dog
- 4 Код, читающий его как Dog[], вызывает метод только-Dog вроде bark()
- 5 В рантайме bark is not a function, и ни одна ошибка компиляции не сработала
Заполните пропуск: параметры функций _______ — функция, принимающая более широкий тип входа, может подменить ту, что принимает более узкий, но не наоборот.
- 01Определите ковариантность, контравариантность и инвариантность через Dog extends Animal и скажите, что применимо к параметрам функций, массивам и изменяемому боксу.
- 02Объясните ловушку метод-против-свойства в strictFunctionTypes и практическое правило, которое из неё следует.
Вариантность — это направление, в котором подтипизация течёт сквозь конструктор типа: ковариантно для производителей, контравариантно для потребителей, которыми являются параметры функций, инвариантно для изменяемых контейнеров, требующих обоих. TypeScript сознательно меняет соундность на эргономику в двух местах — массивы ковариантны несмотря на изменяемость, а параметры в форме метода остаются бивариантными даже под strictFunctionTypes, поэтому слишком узкий колбэк проскальзывает в слот метода и падает в рантайме. Защита — форма объявления свойства-функции для действительно проверяемых колбэков и аннотации in/out (TS 4.7) для утверждения намерения. На этом юнит дженериков закрыт; юнит реального мира вновь вернётся к этим самым дырам как к продакшен-ловушкам. Теперь, когда видишь колбэк, зарегистрированный через метод без ошибки компилятора, — первый вопрос: параметр реально проверен или проскользнул через бивариантность метода?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.