TypeScript Bivariance: 3 Mismatched Type Pitfalls Fixed
Struggling with weird type errors in TypeScript? Learn what function bivariance is, discover 3 common pitfalls it causes, and see how to fix them for safer code.
Alex Miller
A senior software engineer specializing in TypeScript performance and type-safe architecture.
Ever had that sinking feeling? You write some TypeScript, the compiler gives you a green light, and you ship it. Then, a runtime error pops up: TypeError: Cannot read properties of undefined (reading 'breed')
. You trace it back to a perfectly "valid" callback function. How could TypeScript, our guardian of type safety, let this happen?
The culprit, more often than not, is a subtle and powerful feature of TypeScript's type system: function parameter bivariance. It's a fancy term for a behavior that, while sometimes convenient, can punch holes in the type safety we rely on. By default, TypeScript plays a little fast and loose with function argument types to better align with common JavaScript patterns, which can lead to some... unexpected behavior.
In this post, we'll demystify bivariance, explore three real-world pitfalls it creates, and show you the one-line fix that will make your code dramatically safer. Let's turn those runtime surprises into compile-time certainties.
What Are Covariance, Contravariance, and Bivariance?
Before we dive into the pitfalls, let's get our terms straight. Variance describes how subtyping works with more complex types (like functions or arrays). Let's use a classic example: Dog
is a subtype of Animal
.
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }
- Covariance: This feels intuitive. If
Dog
is a subtype ofAnimal
, thenDog[]
is a subtype ofAnimal[]
. You can use an array of dogs where an array of animals is expected. Output positions (like function return types) are typically covariant. It preserves the subtyping direction (narrower is assignable to wider). - Contravariance: This is the opposite and applies to inputs. A function that accepts an
Animal
(e.g.,(a: Animal) => void
) is a subtype of a function that accepts aDog
(e.g.,(d: Dog) => void
). Think about it: a function that can handle any animal can certainly handle a dog. You can use a more general function where a more specific one is expected. It reverses the subtyping direction (wider is assignable to narrower). - Bivariance: This is the wild card. It means a type is both covariant and contravariant. For function parameters, TypeScript's default behavior is bivariant. It allows you to assign a function that takes a more general type or a more specific type. This is where the danger lies.
Here’s a quick comparison:
Variance | Parameter Type Logic (Input) | Safety |
---|---|---|
Covariance | Assigning a wider type to a narrower type (e.g., (dog) => {} to (animal) => {} ) |
Unsafe |
Contravariance | Assigning a narrower type to a wider type (e.g., (animal) => {} to (dog) => {} ) |
Safe |
Bivariance (TS Default) | Allows both of the above | Unsafe |
Pitfall 1: The Event Listener Trap
This is a classic example. You define a function to handle a specific mouse event, but you assign it to a generic event listener. TypeScript, in its default bivariant mode, says "No problem!".
The Problem
Consider this code. We have a handler that specifically expects a MouseEvent
to access clientX
and clientY
.
// This function expects a MouseEvent, not just any Event
function handleMouse(event: MouseEvent) {
console.log(`Mouse coordinates: ${event.clientX}, ${event.clientY}`);
}
// Imagine this is a DOM element
const myElement = { addEventListener: (type: string, listener: (e: Event) => void) => { /* ... */ } };
// TypeScript allows this without any flags!
// But what if the event is a KeyboardEvent?
myElement.addEventListener('keypress', handleMouse);
// At runtime: 'keypress' fires, handleMouse receives a KeyboardEvent,
// and event.clientX is undefined. Oops.
Because the listener
parameter is bivariant by default, TypeScript allows us to pass handleMouse
(which takes a subtype MouseEvent
) where a function taking a supertype Event
is expected. This is covariant behavior, and for inputs, it's unsafe.
The Fix: `strictFunctionTypes`
The solution is to tell TypeScript to be, well, stricter. By enabling strictFunctionTypes
in your tsconfig.json
, you force function parameters to be checked contravariantly.
// tsconfig.json
{
"compilerOptions": {
"strictFunctionTypes": true
}
}
With this flag, the same code now produces a compile-time error, saving you from a runtime headache:
// With "strictFunctionTypes": true
myElement.addEventListener('keypress', handleMouse);
// ~~~~~~~~~~~
// Error: Argument of type '(event: MouseEvent) => void' is not assignable to
// parameter of type '(e: Event) => void'.
// Types of parameters 'event' and 'e' are incompatible.
// Type 'Event' is not assignable to type 'MouseEvent'.
Pitfall 2: The Unsafe Array Method Callback
Array methods like forEach
, map
, and filter
are common places for bivariance to cause trouble, especially when dealing with arrays of subtypes.
The Problem
Let's go back to our Animal
and Dog
types. We have an array of Animal
s that happens to contain only Dog
s. We then try to use a callback that is only valid for Dog
s.
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }
const dogs: Dog[] = [{ name: 'Buddy', breed: 'Labrador' }];
const animals: Animal[] = dogs; // This is fine (array covariance)
// A function that ONLY works for Dogs
const logBreed = (dog: Dog) => {
console.log(dog.breed); // Requires 'breed' property
};
// Without strictFunctionTypes, TypeScript allows this!
animals.forEach(logBreed);
// Now, let's add a Cat to the animal array
interface Cat extends Animal { lives: number; }
const mixedAnimals: Animal[] = [ ...dogs, { name: 'Whiskers', lives: 9 }];
// This will compile fine but crash at runtime!
mixedAnimals.forEach(logBreed); // CRASH! When it gets to the cat, logBreed receives a Cat,
// and dog.breed will be undefined.
The forEach
callback is bivariant. It's allowing us to pass a function that needs a Dog
where the signature requires a function that can handle any Animal
. This is the same unsafe covariant behavior as before.
The Fix: Stricter Checks or Inline Functions
Once again, "strictFunctionTypes": true
is the best solution. It will immediately flag animals.forEach(logBreed)
as an error.
However, if you're in a codebase where you can't enable that flag yet, a safer pattern is to use an inline arrow function. This forces TypeScript to infer the parameter type from the context (`Animal`), preventing you from accessing subtype-specific properties.
// The safe way, even without strictFunctionTypes
mixedAnimals.forEach((animal) => {
// TypeScript correctly infers 'animal' as 'Animal'
console.log(animal.name);
// console.log(animal.breed); // Error! Property 'breed' does not exist on type 'Animal'.
});
Pitfall 3: The Dangerous Higher-Order Function
When you create your own higher-order functions that accept callbacks, you can inadvertently create a bivariance trap for yourself and other developers.
The Problem
Imagine a generic function that processes a list of items using a provided logger callback.
type Logger = (item: T) => void;
// A higher-order function that takes a logger
function processItems(items: T[], logger: Logger) {
items.forEach(logger);
}
const animals: Animal[] = [{ name: 'Leo' }, { name: 'Zoe' }];
const dogLogger: Logger = (dog: Dog) => console.log(dog.breed);
// Without strictFunctionTypes, this is allowed!
// We're passing a logger for Dogs to a function processing Animals.
processItems(animals, dogLogger);
// Runtime Error: Cannot read properties of undefined (reading 'breed')
The type parameter `T` is inferred as Animal
from the `items` array. Therefore, the function expects a Logger
. But due to bivariance, TypeScript allows us to pass a Logger
. The type system is broken.
The Fix: Enforcing Contravariance
You guessed it: "strictFunctionTypes": true
is the definitive fix. It enforces that function parameters are contravariant. With the flag enabled, TypeScript correctly identifies the mismatch:
Logger
is NOT assignable to Logger
.
However, the reverse is true and safe (contravariance):
Logger
IS assignable to Logger
.
// With "strictFunctionTypes": true
const dogs: Dog[] = [{ name: 'Rex', breed: 'German Shepherd' }];
const animalLogger: Logger = (animal: Animal) => console.log(animal.name);
// This is safe and CORRECTLY allowed!
// We're passing a general Animal logger to a function processing Dogs.
// The logger only needs 'name', which every Dog has.
processItems(dogs, animalLogger);
This demonstrates the core principle of contravariance for inputs: you can always provide a function that handles a more general case where a more specific one is required.
The Golden Rule: Enable `strictFunctionTypes`
As we've seen, all three of these pitfalls are resolved by a single compiler option. The strictFunctionTypes
flag was introduced in TypeScript 2.6 to fix this long-standing soundness issue. It's part of the `strict` family of checks, but it's important to know that enabling `strict: true` automatically enables `strictFunctionTypes`.
Why isn't it on by default (without `strict`)? For backward compatibility. Many existing JavaScript and early TypeScript codebases relied on bivariant behavior, and turning this on would have been a massive breaking change. For any new project started today, there is virtually no reason not to enable it.
Conclusion: From Unsafe to Unshakable
TypeScript's default bivariance on function parameters is a pragmatic compromise that can, unfortunately, lead to unsound types and sneaky runtime bugs. By understanding the difference between covariance and contravariance, you can recognize these patterns and defend your code against them.
The takeaway is simple: for robust, predictable, and truly type-safe code, you should always have strictFunctionTypes
enabled. Go check your tsconfig.json
right now. If it's not set to true
, you've just found your next, most impactful refactoring task.