Система типів 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.
Позиція мутабельної властивості (читання і запис) — суворо інваріантна.
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 активує методну біваріантність навіть при прапорі --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 для безпечної роботи зі звуженнями типів.
Перевіряється відношення контраваріантності: очікуваний тип параметра функції зобов'язаний бути підтипом фактичного переданого типу.
Перевіряється відношення коваріантності: тип функції, що повертається, зобов'язаний бути підтипом оголошеного типу результату.
Для всіх властивостей із можливістю запису компілятор вимагає суворого двостороннього еквівалента типів (інваріантність).
Якщо всі три інваріанти дотримані, присвоювання вважається типобезпечним.