36.7 Variance Annotations in TypeScript 4.7+: in and out
Right, so you’ve finally decided to get serious about type safety. Good. You’ve probably built a generic class or two, maybe a Box<T> or a Repository<T>, and thought, “This is fine.” And for a while, it is. Then you try to assign a Box<string> to a Box<string | number> and TypeScript screams at you. You stare at the error, a vein throbbing gently in your forehead, because obviously a box of strings should be assignable to a box of strings-or-numbers. What could possibly go wrong?
Well, my friend, you’ve just smacked headfirst into the brick wall of variance, and TypeScript 4.7 gave us the tools to finally put up some guardrails. Before that, it was a bit of a wild west, and the compiler’s default behavior was, frankly, a bit paranoid. Let’s fix that.
The Problem: Why is Box<string> not a Box<string | number>?
TypeScript uses structural typing, but for generics, it uses a stricter form called invariance by default. This means a Box<Cat> is only assignable to a Box<Animal> if Cat is exactly Animal. It doesn’t care that Cat extends Animal. This is because TypeScript has to assume the worst about your generic class.
Imagine this Box:
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
set(value: T) {
this.value = value; // A write operation
}
get(): T {
return this.value; // A read operation
}
}
const stringBox = new Box('hello');
// @ts-expect-error: Type 'Box<string>' is not assignable to 'Box<string | number>'.
const stringOrNumberBox: Box<string | number> = stringBox;
Why the error? Let’s say TypeScript allowed it. We now have stringOrNumberBox, which the type system believes can hold a string | number, but it’s actually our original stringBox under the hood, which can only hold a string.
Now, what happens if we try to use it?
// If the assignment above were allowed, this would be "legal":
stringOrNumberBox.set(42); // We're trying to put a number into a Box<string>!
const whatIsThis: string = stringBox.get(); // Runtime error: number where a string is expected.
Boom. The type system is broken. The set method makes this type unsafe for covariance (where you want Box<Cat> to be a Box<Animal>). The default invariant behavior is, in this case, the correct and safe choice. It’s annoying, but it’s saving you from yourself.
The Solution: Telling TypeScript Your Intent with out
What if our Box was read-only? If we removed the set method, the entire problem vanishes. There’s no way to write a number into it, so it’s perfectly safe to treat a Box<string> as a Box<string | number>. This is called covariance: the type parameter varies with its direction (T in Box<T> varies in the same direction as the assignment).
TypeScript 4.7+ lets you explicitly declare this intent using the out annotation.
class ReadOnlyBox<out T> {
private value: T;
constructor(value: T) {
this.value = value;
}
get(): T {
return this.value;
}
}
const roStringBox = new ReadOnlyBox('hello');
// Now, this works perfectly! No error.
const roStringOrNumberBox: ReadOnlyBox<string | number> = roStringBox;
By marking T with out, you’re telling the type checker, “I promise this type parameter is only used in an output position (like return types).” You’re signing a contract. Try to add a set(value: T) method now. The compiler will immediately stop you, yelling that you can’t use a covariant type parameter in an input position. It’s brilliantly strict and prevents you from making the deadly mistake we imagined earlier.
The Flip Side: Contravariance with in
Now, let’s consider the opposite scenario. Imagine a function type, like a comparator:
interface Comparator<T> {
compare(a: T, b: T): number;
}
Is a Comparator<Animal> assignable to a Comparator<Cat>? Let’s think.
If I need something that can compare two Cats, can I use a thing that knows how to compare any two Animals? Absolutely! It’s a more general tool, perfectly suited for the more specific job. This is contravariance: the type parameter varies against its direction (Animal in Comparator<Animal> is assignable to Comparator<Cat>).
We can enforce this with the in annotation:
interface Comparator<in T> {
compare(a: T, b: T): number;
}
const animalComparator: Comparator<Animal> = {
compare(a, b) { return 0; } // simplistic implementation
};
// This assignment is now safe and allowed!
const catComparator: Comparator<Cat> = animalComparator;
The in keyword tells the compiler, “I promise this type parameter is only ever used in an input position (like function parameters).” This is why you can’t use an in parameter in an output position, like a return type. It would be unsafe.
Bivariance and The Ultimate Safety: in out
You can also declare a type parameter as invariant, which is the default, by not using either keyword. But if you want to be explicitly invariant, you can use both. This is useful for classes that truly need to both read and write.
// This is what the default Box<T> effectively was. Now we can be explicit.
class SecureBox<in out T> {
private value: T;
constructor(value: T) {
this.value = value;
}
set(value: T) {
this.value = value;
}
get(): T {
return this.value;
}
}
With in out, the compiler will enforce that T is used everywhere correctly. A SecureBox<string> is now, and always will be, only a SecureBox<string>. It cannot be assigned to or from a SecureBox<string | number>. This is the maximum safety setting, and you use it when you need absolute guarantees about the integrity of the contained type.
Best Practices and When to Reach for This
- Don’t Just Sprinkle It Everywhere: This is a powerful feature for library authors and creators of complex, reusable type infrastructure. For your average app-level
UserRepository, it’s probably overkill. Use it when the default invariance is causing legitimate, painful friction in your codebase. - Clarity Over Cleverness: The huge win here is documentation. Seeing
out Timmediately tells anyone reading your code, “This generic is covariant and safe to use in this way.” It makes your intentions crystal clear to both the compiler and other humans. - It’s a Contract: Remember, you’re not making the type covariant or contravariant; you’re declaring that it already is by its usage. The keywords are a way to assert that safety and have the compiler check your work. If you break the contract, the compiler becomes your very annoyed, very correct code reviewer.
This feature moves TypeScript from merely checking types to understanding the flow of those types through your code. It’s a step towards the kind of type-level programming that makes other languages envious, and it does it without sacrificing the practicality that makes TypeScript, well, TypeScript.