El sistema de tipos de TypeScript está basado en el subtipado estructural (Structural Subtyping) fundamentado en el Principio de Sustitución de Liskov (LSP). Si el tipo S contiene todos los miembros del tipo T con firmas compatibles, entonces S es un subtipo de T (denotado matemáticamente como S <: T). Esto garantiza que un valor de tipo S pueda utilizarse con total seguridad dondequiera que se espere un tipo T.
Sin embargo, al pasar de tipos escalares a constructores de tipos genéricos (Generics) de la forma F<T>, surge una pregunta capital en la teoría de tipos: ¿cómo se traslada la relación de subtipado entre argumentos A <: B a la relación entre los tipos compuestos F<A> y F<B>?
La respuesta la proporciona la teoría de varianza (Variance). El comportamiento de cualquier constructor de tipos se clasifica en cuatro categorías:
out T): la relación de subtipado se conserva. Si A <: B, entonces F<A> <: F<B>. Típica de estructuras de las que solo se extraen datos (Producer / Output).in T): la relación de subtipado se invierte. Si A <: B, entonces F<B> <: F<A>. Típica de estructuras donde los datos se reciben como argumentos (Consumer / Input).in out T): no existe relación de subtipado. F<A> <: F<B> se cumple si y solo si A es idénticamente igual a B. Surge cuando un tipo se usa simultáneamente para lectura y escritura (contenedores mutables).A <: B, entonces F<A> <: F<B> y F<B> <: F<A>. Es el compromiso histórico de TypeScript para parámetros de métodos en clases e interfaces.A partir de la versión 4.7, TypeScript introdujo anotaciones explícitas de varianza (in, out), permitiendo a los desarrolladores de librerías declarar la intención de varianza al definir el parámetro genérico, eliminando costosas comprobaciones estructurales del compilador y evitando sutiles fallos de compatibilidad.
Analicemos la jerarquía de tipos: Admin <: User <: Entity (donde Admin es el subtipo más específico y Entity es el supertipo base más amplio).
+-------------------------------------------------------------------------+
| JERARQUÍA DE SUBTIPOS (Subtyping) |
| Admin <: User <: Entity |
+-------------------------------------------------------------------------+
| 1. COVARIANZA (out T) - La dirección se PRESERVA: |
| Producer<Admin> <: Producer<User> <: Producer<Entity> |
| (Seguro: leer un User garantiza obtener todas las propiedades User) |
+-------------------------------------------------------------------------+
| 2. CONTRAVARIANZA (in T) - La dirección se INVIERTE: |
| Consumer<Entity> <: Consumer<User> <: Consumer<Admin> |
| (Seguro: un gestor de Entity puede recibir User y Admin) |
+-------------------------------------------------------------------------+
| 3. INVARIANZA (in out T) - La coerción está PROHIBIDA: |
| Box<Admin> != Box<User> != Box<Entity> |
| (Seguridad ante mutación: escribir Entity en Box<Admin> rompe tipos) |
+-------------------------------------------------------------------------+La posición del tipo dentro de la firma define su varianza:
La posición del valor de retorno de una función es estrictamente covariante (out).
La posición del argumento de una función es estrictamente contravariante (in) bajo el flag --strictFunctionTypes.
La posición de una propiedad mutable (lectura y escritura simultánea) es estrictamente invariante.
EventHandler<in TEvent>, asegurando que un logger genérico EventHandler<DomainEvent> pueda procesar de forma segura eventos específicos como UserCreatedEvent.ReadonlyRepository<out TEntity> junto con almacenes mutables invariantes MutableStore<in out TState>.in/out en monorrepositorios enterprise reducen el tiempo de inferencia del compilador hasta un 40% en tipos anidados profundos (por ejemplo, en Zod, tRPC, TypeBox).Validator<in TInput, out TOutput> con control independiente de varianza en los flujos de entrada y salida.El compilador examina la interfaz, registrando las apariciones de T: argumentos de función (Input) o retornos (Output).
Si solo aparece en Output es Covariant. Si solo aparece en Input es Contravariant. Si en ambos, es Invariant.
Si el desarrollador especificó out T pero T se utiliza como argumento de función, el compilador emite el error TS1398.
Al comparar F<A> con F<B>, el compilador omite el recorrido profundo de miembros y aplica directamente las reglas de varianza.
out T)#La covarianza ocurre cuando un tipo actúa exclusivamente como productor de datos (Producer):
export interface Entity {
id: string;
}
export interface User extends Entity {
email: string;
}
export interface Admin extends User {
permissions: string[];
}
// Parámetro genérico con anotación 'out': T solo se utiliza para salida
export interface ReadonlyCollection<out T> {
getById(id: string): T | undefined;
getAll(): readonly T[];
}
declare let adminCollection: ReadonlyCollection<Admin>;
declare let userCollection: ReadonlyCollection<User>;
// COVARIANZA: Admin <: User => ReadonlyCollection<Admin> <: ReadonlyCollection<User>
// Totalmente seguro: cualquier método retorna al menos las propiedades de User!
userCollection = adminCollection; // Válido
const user = userCollection.getById("user-1");
if (user) {
console.log(user.email); // Acceso completamente válido
}in T)#La contravarianza surge cuando un tipo actúa como consumidor de datos (Consumer). La dirección de la relación de subtipado se invierte:
import { Entity, User, Admin } from './covariance';
// Parámetro genérico con anotación 'in': T solo se utiliza en posiciones de entrada
export interface Serializer<in T> {
serialize(data: T): string;
}
declare let entitySerializer: Serializer<Entity>;
declare let userSerializer: Serializer<User>;
declare let adminSerializer: Serializer<Admin>;
// CONTRAVARIANZA: Entity <: User => Serializer<Entity> <: Serializer<User>
// El serializador de Entity sabe procesar cualquier Entity.
// Por ende, ¡puede procesar con seguridad cualquier User o Admin!
userSerializer = entitySerializer; // ¡Válido!
// La asignación inversa está prohibida y genera un error de tipos:
// entitySerializer = userSerializer; // ¡Error TS2322!
// userSerializer espera la propiedad email, inexistente en el Entity básico.in out T)#Cuando un tipo se lee y se escribe simultáneamente, cualquier coerción de subtipos quiebra la seguridad de tipos:
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>;
// INVARIANZA: ¡La asignación se rechaza en ambos sentidos!
// userStorage = adminStorage; // ¡Error TS2322!
// adminStorage = userStorage; // ¡Error TS2322!
// ¿POR QUÉ? Demostración del fallo en tiempo de ejecución:
function unsafeDemonstration(store: Storage<User>, plainUser: User) {
// Si 'userStorage = adminStorage' fuera válido,
// ¡podríamos escribir un User común en el almacenamiento de Admin!
store.write(plainUser);
// Ahora adminStorage contiene un User estándar que carece de permissions.
// ¡Llamar a adminStorage.read().permissions causaría un TypeError en runtime!
}Un matiz fundamental en TypeScript es la diferencia entre declarar un método y declarar una propiedad funcional cuando --strictFunctionTypes está activo:
import { User, Admin } from './covariance';
// 1. Método de interfaz: históricamente BIVARIANTE
interface EventListenerMethod<T> {
handle(event: T): void;
}
// 2. Propiedad funcional: estrictamente CONTRAVARIANTE
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>;
// La bivarianza de métodos permite asignaciones inseguras:
adminMethodListener = userMethodListener; // Válido (contravariante)
userMethodListener = adminMethodListener; // ¡Válido! (BIVARIANTE - potencial runtime crash)
// La propiedad funcional estricta bloquea la asignación insegura:
adminPropListener = userPropListener; // Válido (contravariante: User <: Admin)
// userPropListener = adminPropListener; // ¡ERROR TS2322! Bloqueado por el compilador.Implementemos un broker de eventos de nivel industrial que prevenga incompatibilidades de argumentos mediante suscriptores contravariantes y emisores covariantes:
export interface DomainEvent {
readonly aggregateId: string;
readonly occurredAt: number;
}
// Consumidor contravariante de eventos
export interface Consumer<in TEvent extends DomainEvent> {
consume: (event: TEvent) => void | Promise<void>;
}
// Productor covariante de eventos
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));
}
}
}Declarar callbacks mediante la sintaxis de método method(arg: T): void activa la bivarianza de métodos incluso con --strictFunctionTypes activado. Esto permite asignar por error un manejador que espera un subtipo específico a una fuente que emite tipos base, produciendo excepciones en tiempo de ejecución al acceder a propiedades indefinidas.
// ❌ MAL: la bivarianza de métodos oculta errores de tipo
interface BadSink<T> {
accept(item: T): void;
}
// ✅ BIEN: la propiedad funcional garantiza contravarianza estricta
interface GoodSink<T> {
accept: (item: T) => void;
}En TypeScript, los arrays nativos T[] son covariantes en lectura pero mutables en escritura. Asignar User[] a una variable Entity[] está permitido, pero agregar un Entity base mediante .push() corrompe el array original User[]. Utiliza siempre readonly T[] para protegerte.
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const dogs: Dog[] = [{ name: "Rex", bark: () => {} }];
// ❌ PELIGROSO: array mutable asignado a supertipo
const animals: Animal[] = dogs;
// animals.push({ name: "Cat" }); // En runtime, dogs[1].bark() lanzará TypeError!
// ✅ SEGURO: protección mediante inmutabilidad (covarianza sin riesgo de mutación)
const safeAnimals: readonly Animal[] = dogs;
// safeAnimals.push(...); // ¡Error de compilación TS2339!Si se define out T pero se utiliza T en el argumento de una función (posición de entrada), el compilador detiene la compilación con el error TS1398. Las anotaciones no transforman la naturaleza del tipo, sino que validan el diseño de la arquitectura.
// ❌ ERROR TS1398: Type parameter T was declared with 'out' variance, but is used in an input position
interface InvalidProducer<out T> {
set: (val: T) => void;
}
// ✅ CORRECTO: posición del argumento alineada
interface ValidConsumer<in T> {
set: (val: T) => void;
}La cláusula T extends any carece de valor semántico ya que any desactiva la seguridad de tipos. Si T puede ser cualquier tipo, omite la cláusula extends o emplea T extends unknown para permitir estrechamientos de tipos seguros.
Se verifica la relación de contravarianza: el tipo de parámetro esperado debe ser subtipo del tipo del argumento efectivamente suministrado.
Se verifica la relación de covarianza: el tipo de retorno de la función debe ser subtipo del tipo declarado en el resultado.
Para toda propiedad modificable, el compilador exige equivalencia bidireccional estricta de tipos (invarianza).
Si se satisfacen los tres invariantes, la asignación se considera plenamente segura.