Back to Blog
TypeScriptVarianceGenericsType Safety

TypeScript Variance Annotations Guide

Master TypeScript variance annotations (in/out) for precise generic type parameter control.

B
Bootspring Team
Engineering
October 2, 2019
6 min read

Variance annotations explicitly declare how generic type parameters can be used. Here's how to apply them.

Basic Concepts#

1// Covariance (out) - type flows out 2interface Producer<out T> { 3 produce(): T; 4} 5 6// Contravariance (in) - type flows in 7interface Consumer<in T> { 8 consume(value: T): void; 9} 10 11// Invariance - type flows both ways 12interface Processor<T> { 13 process(value: T): T; 14} 15 16// Bivariance - historical default for methods

Covariance with out#

1// Producer can only output T 2interface Reader<out T> { 3 read(): T; 4 peek(): T | undefined; 5} 6 7// Animal hierarchy 8class Animal { 9 name = 'animal'; 10} 11 12class Dog extends Animal { 13 breed = 'unknown'; 14} 15 16// Covariance allows upcasting 17const dogReader: Reader<Dog> = { 18 read: () => new Dog(), 19 peek: () => new Dog(), 20}; 21 22// Dog is subtype of Animal, so Reader<Dog> is subtype of Reader<Animal> 23const animalReader: Reader<Animal> = dogReader; // OK 24 25// This would be an error with 'out': 26interface InvalidReader<out T> { 27 read(): T; 28 // write(value: T): void; // Error: T is used in 'in' position 29}

Contravariance with in#

1// Consumer can only accept T 2interface Writer<in T> { 3 write(value: T): void; 4} 5 6// Contravariance allows downcasting 7const animalWriter: Writer<Animal> = { 8 write: (animal) => console.log(animal.name), 9}; 10 11// Animal is supertype of Dog, so Writer<Animal> is subtype of Writer<Dog> 12const dogWriter: Writer<Dog> = animalWriter; // OK 13 14// This would be an error with 'in': 15interface InvalidWriter<in T> { 16 write(value: T): void; 17 // read(): T; // Error: T is used in 'out' position 18}

Combined Annotations#

1// Different type parameters with different variance 2interface Transform<in I, out O> { 3 transform(input: I): O; 4} 5 6// Mapper with contravariant input, covariant output 7type Mapper<in T, out U> = (value: T) => U; 8 9// Event handler pattern 10interface EventHandler<in E> { 11 handle(event: E): void; 12} 13 14// Factory pattern 15interface Factory<out T> { 16 create(): T; 17 createMany(count: number): T[]; 18} 19 20// Repository pattern 21interface ReadOnlyRepository<out T> { 22 find(id: string): T | undefined; 23 findAll(): T[]; 24} 25 26interface WriteOnlyRepository<in T> { 27 save(entity: T): void; 28 delete(entity: T): void; 29} 30 31interface Repository<T> extends ReadOnlyRepository<T>, WriteOnlyRepository<T> {}

Type Checking Improvements#

1// Without variance annotations - may have unexpected behavior 2interface OldBox<T> { 3 value: T; 4 getValue(): T; 5 setValue(v: T): void; 6} 7 8// With explicit variance - clearer intent and stricter checking 9interface ReadonlyBox<out T> { 10 readonly value: T; 11 getValue(): T; 12} 13 14interface WritableBox<in T> { 15 setValue(v: T): void; 16} 17 18interface Box<T> extends ReadonlyBox<T>, WritableBox<T> { 19 value: T; 20}

Callback Patterns#

1// Callback with contravariant parameter 2interface Callback<in T> { 3 (value: T): void; 4} 5 6// Event listener 7interface Listener<in E> { 8 onEvent(event: E): void; 9} 10 11// More specific callbacks work where less specific needed 12const animalCallback: Callback<Animal> = (a) => console.log(a.name); 13const dogCallback: Callback<Dog> = animalCallback; // OK 14 15// Promise-like with covariant result 16interface Thenable<out T> { 17 then<U>(onFulfilled: (value: T) => U): Thenable<U>; 18}

State Management#

1// Read-only state selector (covariant) 2interface Selector<out T> { 3 select(): T; 4 subscribe(listener: () => void): () => void; 5} 6 7// Action dispatcher (contravariant) 8interface Dispatcher<in A> { 9 dispatch(action: A): void; 10} 11 12// Store combines both 13interface Store<S, in A> { 14 getState(): S; 15 dispatch(action: A): void; 16 subscribe(listener: () => void): () => void; 17} 18 19// Type-safe selectors 20interface StateSelector<S, out T> { 21 (state: S): T; 22} 23 24function createSelector<S, out T>( 25 selector: (state: S) => T 26): StateSelector<S, T> { 27 return selector; 28}

Collection Types#

1// Immutable/read-only collection (covariant) 2interface ReadonlyList<out T> { 3 get(index: number): T | undefined; 4 [Symbol.iterator](): Iterator<T>; 5 readonly length: number; 6} 7 8// Mutable collection (invariant) 9interface MutableList<T> extends ReadonlyList<T> { 10 set(index: number, value: T): void; 11 push(value: T): void; 12} 13 14// Consumer-only collection (contravariant) 15interface Sink<in T> { 16 add(value: T): void; 17 addAll(values: Iterable<T>): void; 18} 19 20// Usage 21const dogs: ReadonlyList<Dog> = [new Dog()]; 22const animals: ReadonlyList<Animal> = dogs; // OK - covariant

Observer Pattern#

1// Subject with contravariant observer type 2interface Subject<in T> { 3 addObserver(observer: Observer<T>): void; 4 removeObserver(observer: Observer<T>): void; 5} 6 7// Observer with contravariant event type 8interface Observer<in T> { 9 onNext(value: T): void; 10 onError?(error: Error): void; 11 onComplete?(): void; 12} 13 14// Observable with covariant emission type 15interface Observable<out T> { 16 subscribe(observer: Observer<T>): Subscription; 17} 18 19interface Subscription { 20 unsubscribe(): void; 21}

Builder Pattern#

1// Builder produces T (covariant) 2interface Builder<out T> { 3 build(): T; 4} 5 6// Configurable builder accepts configuration (contravariant) 7interface ConfigurableBuilder<in C, out T> { 8 configure(config: C): this; 9 build(): T; 10} 11 12// Usage 13interface UserConfig { 14 name: string; 15 email: string; 16} 17 18interface AdminConfig extends UserConfig { 19 permissions: string[]; 20} 21 22class UserBuilder implements ConfigurableBuilder<UserConfig, User> { 23 private config: Partial<UserConfig> = {}; 24 25 configure(config: UserConfig) { 26 this.config = { ...this.config, ...config }; 27 return this; 28 } 29 30 build(): User { 31 return new User(this.config as UserConfig); 32 } 33} 34 35// AdminConfig is subtype of UserConfig 36// So ConfigurableBuilder<UserConfig, User> accepts AdminConfig 37const builder: ConfigurableBuilder<AdminConfig, User> = new UserBuilder();

Error Handling#

1// Result type with covariant success, covariant error 2type Result<out T, out E = Error> = 3 | { ok: true; value: T } 4 | { ok: false; error: E }; 5 6// Mapper for success value only 7function mapResult<T, U, E>( 8 result: Result<T, E>, 9 fn: (value: T) => U 10): Result<U, E> { 11 if (result.ok) { 12 return { ok: true, value: fn(result.value) }; 13 } 14 return result; 15} 16 17// Result<Dog, Error> is subtype of Result<Animal, Error> 18const dogResult: Result<Dog> = { ok: true, value: new Dog() }; 19const animalResult: Result<Animal> = dogResult; // OK

Best Practices#

When to Use: ✓ Library/framework code ✓ Generic interfaces ✓ Complex type hierarchies ✓ API design Benefits: ✓ Explicit variance intent ✓ Better error messages ✓ Faster type checking ✓ Clearer documentation Patterns: ✓ out for producers/getters ✓ in for consumers/setters ✓ No annotation for invariant ✓ Combine for transformers Avoid: ✗ Overusing on simple types ✗ Ignoring variance errors ✗ Breaking encapsulation ✗ Adding without understanding

Conclusion#

Variance annotations (in/out) make generic type parameter usage explicit. Use out for types that only produce values (covariant), in for types that only consume values (contravariant), and no annotation for types that do both (invariant). This improves type safety and helps TypeScript provide better error messages.

Share this article

Help spread the word about Bootspring