Система типов TypeScript базируется на структурном подтипировании (Structural Subtyping), опирающемся на принцип подстановки Лисков (Liskov Substitution Principle). Если тип S содержит все члены типа T с совместимыми сигнатурами, то S является подтипом T (математически обозначается как S <: T). Это означает, что значение типа S может быть безопасно использовано там, где ожидается тип T.
Однако при переходе от скалярных типов к обобщенным конструкторам типов (Generics) вида F<T> возникает фундаментальный вопрос теории типов: как отношение подтипов между аргументами A <: B транслируется в отношение подтипов между сложными типами F<A> и F<B>?
Ответ на этот вопрос дает теория вариативности (Variance). Поведение конструктора типа классифицируется по четырем категориям:
out T): отношение подтипов сохраняется. Если A <: B, то F<A> <: F<B>. Характерна для структур, из которых данные только считываются (Producer / Output).in T): отношение подтипов обращается вспять. Если A <: B, то F<B> <: F<A>. Характерна для структур, в которые данные передаются как аргументы (Consumer / Input).in out T): отношение подтипов отсутствует. F<A> <: F<B> выполняется тогда и только тогда, когда A тождественно равен B. Возникает, когда тип используется одновременно на чтение и на запись (мутабельные контейнеры).A <: B, то F<A> <: F<B> и F<B> <: F<A>. Исторический компромисс TypeScript для параметров методов классов и интерфейсов.Начиная с версии 4.7, TypeScript ввел явные аннотации вариативности (in, out), позволяющие разработчикам библиотек декларировать намерение вариативности на этапе объявления дженерика, устраняя дорогостоящие структурные проверки компилятора и предотвращая скрытые дефекты совместимости.
Рассмотрим иерархию типов: Admin <: User <: Entity (где Admin — наиболее специфичный подтип, а Entity — наиболее широкий базовый супертип).
+-------------------------------------------------------------------------+
| ИЕРАРХИЯ ПОДТИПОВ (Subtyping) |
| Admin <: User <: Entity |
+-------------------------------------------------------------------------+
| 1. КОВАРИАНТНОСТЬ (out T) - Направление сохраняется: |
| Producer<Admin> <: Producer<User> <: Producer<Entity> |
| (Безопасно: читатель User гарантированно получит поля User) |
+-------------------------------------------------------------------------+
| 2. КОНТРАВАРИАНТНОСТЬ (in T) - Направление ОБРАЩАЕТСЯ: |
| Consumer<Entity> <: Consumer<User> <: Consumer<Admin> |
| (Безопасно: обработчик Entity готов принять и User, и Admin) |
+-------------------------------------------------------------------------+
| 3. ИНВАРИАНТНОСТЬ (in out T) - Приведение ЗАПРЕЩЕНО: |
| Box<Admin> != Box<User> != Box<Entity> |
| (Защита от мутаций: запись Entity в Box<Admin> сломала бы типы) |
+-------------------------------------------------------------------------+Позиция типа в сигнатуре определяет его вариативность:
Позиция возвращаемого значения функции — строго ковариантна (out).
Позиция аргумента функции — строго контравариантна (in) при флаге компилятора --strictFunctionTypes.
Позиция мутабельного свойства (чтение и запись) — строго инвариантна.
Проверяется отношение контравариантности: ожидаемый тип параметра функции обязан быть подтипом фактического передаваемого типа.
Проверяется отношение ковариантности: возвращаемый тип функции обязан быть подтипом объявленного типа результата.
Для всех свойств с возможностью записи компилятор требует строгого двустороннего эквивалента типов (инвариантность).
Если все три инварианта соблюдены, присваивание считается типобезопасным.
Если Admin является подтипом User (Admin <: User), почему Consumer<User> является подтипом Consumer<Admin> (контравариантность)?
EventHandler<in TEvent>, гарантирующая, что обобщенный логгер EventHandler<DomainEvent> может безопасно обрабатывать специфичные UserCreatedEvent.ReadonlyRepository<out TEntity> и инвариантные мутабельные хранилища MutableStore<in out TState>.in/out в масштабных enterprise-монорепозиториях сокращают время инференса компилятора до 40% на глубоких типах (например, в Zod, TRPC, TypeBox).Validator<in TInput, out TOutput> с раздельным контролем вариативности входных и выходных потоков.Компилятор исследует структуру интерфейса, маркируя позиции использования T: аргументы функций (Input) или возвращаемые типы (Output).
Если позиция только Output — вычисляется Covariant. Если только Input — Contravariant. Если обе — Invariant.
Если разработчик указал out T, но T используется в аргументе функции, компилятор выбрасывает ошибку TS1398.
При сравнении F<A> и F<B> компилятор не обходит все внутренние поля, а мгновенно сверяет A и B по правилам вариативности.
out T)#Ковариантность возникает, когда тип выступает исключительно в роли поставщика данных (Producer):
export interface Entity {
id: string;
}
export interface User extends Entity {
email: string;
}
export interface Admin extends User {
permissions: string[];
}
// Дженерик помечен аннотацией 'out': T используется только на вывод
export interface ReadonlyCollection<out T> {
getById(id: string): T | undefined;
getAll(): readonly T[];
}
declare let adminCollection: ReadonlyCollection<Admin>;
declare let userCollection: ReadonlyCollection<User>;
// КОВАРИАНТНОСТЬ: Admin <: User => ReadonlyCollection<Admin> <: ReadonlyCollection<User>
// Это полностью безопасно: любой метод возвращает как минимум поля User!
userCollection = adminCollection; // Валидно
const user = userCollection.getById("user-1");
if (user) {
console.log(user.email); // Корректный доступ
}in T)#Контравариантность возникает, когда тип выступает в роли потребителя (Consumer). Направление отношения подтипов меняется на противоположное:
import { Entity, User, Admin } from './covariance';
// Дженерик помечен аннотацией 'in': T используется только на вход
export interface Serializer<in T> {
serialize(data: T): string;
}
declare let entitySerializer: Serializer<Entity>;
declare let userSerializer: Serializer<User>;
declare let adminSerializer: Serializer<Admin>;
// КОНТРАВАРИАНТНОСТЬ: Entity <: User => Serializer<Entity> <: Serializer<User>
// Сериализатор Entity умеет работать с любым Entity.
// Значит, ему можно безопасно передать User или Admin!
userSerializer = entitySerializer; // Валидно!
// Обратное присваивание НЕДОПУСТИМО и вызовет ошибку типов:
// entitySerializer = userSerializer; // Ошибка TS2322!
// userSerializer ожидает поля email, которых нет у базового Entity.in out T)#Если тип одновременно считывается и записывается, любое приведение подтипов разрушит типобезопасность:
import { User, Admin } from './covariance';
export interface Storage<in out T> {
read(id: string): T;
write(item: T): void;
}
declare let adminStorage: Storage<Admin>;
declare let userStorage: Storage<User>;
// ИНВАРИАНТНОСТЬ: Присваивание запрещено в обе стороны!
// userStorage = adminStorage; // Ошибка TS2322!
// adminStorage = userStorage; // Ошибка TS2322!
// ПОЧЕМУ? Анализ разрушения системы типов при гипотетическом допущении:
function unsafeDemonstration(store: Storage<User>, plainUser: User) {
// Если бы присваивание 'userStorage = adminStorage' было валидным,
// то через ссылку userStorage мы смогли бы записать обычного User в хранилище Admin!
store.write(plainUser);
// Теперь adminStorage содержит обычного User, у которого отсутствует поле permissions.
// При вызове adminStorage.read().permissions возник бы runtime TypeError!
}Одной из фундаментальных особенностей TypeScript является различие между объявлением метода и объявлением функционального свойства при включенном флаге --strictFunctionTypes:
import { User, Admin } from './covariance';
// 1. Метод интерфейса: исторически БИВАРИАНТЕН
interface EventListenerMethod<T> {
handle(event: T): void;
}
// 2. Функциональное свойство: строго КОНТРАВАРИАНТНО
interface EventListenerProperty<T> {
handle: (event: T) => void;
}
declare let adminMethodListener: EventListenerMethod<Admin>;
declare let userMethodListener: EventListenerMethod<User>;
declare let adminPropListener: EventListenerProperty<Admin>;
declare let userPropListener: EventListenerProperty<User>;
// Методная бивариантность допускает опасное присваивание:
adminMethodListener = userMethodListener; // Валидно (контравариантно)
userMethodListener = adminMethodListener; // Валидно! (БИВАРИАНТНОСТЬ - потенциальный runtime crash)
// Строгое функциональное свойство блокирует опасное присваивание:
adminPropListener = userPropListener; // Валидно (контравариантно: User <: Admin)
// userPropListener = adminPropListener; // ОШИБКА TS2322! Заблокировано компилятором.Разработаем промышленную реализацию шины событий, исключающую ошибки несоответствия аргументов за счет контравариантного подписчика и ковариантного эмиттера:
export interface DomainEvent {
readonly aggregateId: string;
readonly occurredAt: number;
}
// Контравариантный потребитель событий
export interface Consumer<in TEvent extends DomainEvent> {
consume: (event: TEvent) => void | Promise<void>;
}
// Ковариантный генератор событий
export interface Producer<out TEvent extends DomainEvent> {
poll: () => readonly TEvent[];
}
export class TypeSafeEventBus<TBaseEvent extends DomainEvent> {
private handlers = new Map<string, Set<(e: unknown) => void>>();
public subscribe<TEvent extends TBaseEvent>(
eventName: string,
consumer: Consumer<TEvent>
): () => void {
let set = this.handlers.get(eventName);
if (!set) {
set = new Set();
this.handlers.set(eventName, set);
}
const handlerWrapper = (e: unknown) => consumer.consume(e as TEvent);
set.add(handlerWrapper);
return () => {
set?.delete(handlerWrapper);
};
}
public publish(eventName: string, event: TBaseEvent): void {
const set = this.handlers.get(eventName);
if (set) {
set.forEach(fn => fn(event));
}
}
}Почему объявление функции как метода (method(arg: T): void) опаснее функционального свойства (method: (arg: T) => void)?
Объявление функций в виде методов method(arg: T): void активирует методную бивариантность даже при флаге --strictFunctionTypes. Это позволяет случайно присвоить обработчик, ожидающий специфичный подтип, источнику базовых типов, что приводит к вызову методов undefined в рантайме.
// ❌ ПЛОХО: методная бивариантность скрывает ошибку типов
interface BadSink<T> {
accept(item: T): void;
}
// ✅ ПРАВИЛЬНО: функциональное свойство обеспечивает строгую контравариантность
interface GoodSink<T> {
accept: (item: T) => void;
}В TypeScript встроенные массивы T[] ковариантны по чтению, но мутабельны. Присваивание User[] переменной с типом Entity[] разрешено системой типов, однако добавление базового Entity в общий массив ломает инвариант для исходного массива User[]. Для защиты используйте readonly T[].
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const dogs: Dog[] = [{ name: "Rex", bark: () => {} }];
// ❌ ОПАСНО: мутабельный массив приведен к супертипу
const animals: Animal[] = dogs;
// animals.push({ name: "Cat" }); // В рантайме dogs[1].bark() вызовет TypeError!
// ✅ ПРАВИЛЬНО: защита иммутабельностью (ковариантность без риска мутации)
const safeAnimals: readonly Animal[] = dogs;
// safeAnimals.push(...); // Ошибка компиляции TS2339!Если разработчик указывает аннотацию out T, но использует T в аргументе функции (входная позиция), компилятор прерывает сборку с ошибкой TS1398. Аннотация не "превращает" тип насильно, а валидирует правильность архитектуры.
// ❌ ОШИБКА TS1398: Type parameter T was declared with 'out' variance, but is used in an input position
interface InvalidProducer<out T> {
set: (val: T) => void;
}
// ✅ ПРАВИЛЬНО: согласованная позиция аргумента
interface ValidConsumer<in T> {
set: (val: T) => void;
}Конструкция T extends any лишена семантического смысла, так как any отключает типобезопасность. Если необходимо зафиксировать, что T может быть любым типом, не указывайте extends вовсе или используйте T extends unknown для безопасной работы с сужениями типов.