TypeScript 6.0 Strict Function Types: Why Contravariance Breaks Your Existing Callbacks
This article was written with the assistance of AI, under human supervision and review.
Most TypeScript migration failures stem from a single misunderstood compiler flag: strictFunctionTypes. The pattern that breaks production is deceptively simple—a callback that accepts a base type where the consumer expects a derived type. TypeScript 6.0 enables strict mode by default, which means codebases that never configured contravariance checking will fail to compile overnight.
The failure mode here is subtle but expensive. A callback registered to an array method expects Animal, but the implementation passes Dog. Pre-6.0 TypeScript allowed this through bivariant parameter checking. Post-6.0, the compiler rejects it as unsafe. Teams scramble to fix hundreds of type errors without understanding the underlying variance rules, often choosing any or incorrect casts that introduce runtime bugs. The distinction between function properties and method signatures becomes critical—one enforces contravariance, the other permits bivariance for historical reasons.
%% alt: Bivariant checking allows derived types where base types are expected
The correct approach requires understanding contravariance: function parameters must accept types that are the same or less specific than what the function signature declares. When strictFunctionTypes activates, TypeScript enforces this rule for function properties but not method signatures. The solution is not to weaken types with any, but to restructure callbacks using proper variance-aware patterns or switch to method syntax where bivariance is intentional.
%% alt: Contravariant checking enforces parameter safety at compile time
This matters because the TypeScript 6.0 ecosystem assumes strict mode. Third-party libraries ship types built for contravariance. Disabling strictFunctionTypes to silence errors creates a type system that diverges from reality, where the compiler promises safety it cannot enforce. Production teams need a clear migration path that preserves type safety while fixing legitimate variance violations.
Key Takeaways
- TypeScript 6.0 enables
strictFunctionTypesby default, enforcing contravariant parameter checking for function properties and breaking callbacks that relied on bivariant behavior. - Contravariance requires function parameters to accept the same or more general types than declared—a callback accepting
Animalcannot safely be called withDogunder strict checking. - Method signatures retain bivariant checking for compatibility, while function properties enforce contravariance—the choice between
foo(x: T): voidandfoo: (x: T) => voiddetermines variance behavior. - Migration strategies include widening parameter types to unions, using method syntax where bivariance is intentional, or introducing generic constraints that preserve assignability without
any. - The failure mode is expensive: disabling strict checking or using type assertions creates a false sense of safety while reintroducing the runtime bugs contravariance was designed to prevent.
What Is Contravariance and Why Does TypeScript Care?
Contravariance describes how function parameter types behave when assigning one function to another. The rule is counterintuitive at first: a function that accepts a more general parameter type can substitute for one that accepts a more specific type. In other words, if a callback expects Dog, you can safely pass a function that accepts Animal, but not the reverse. TypeScript enforces this through contravariant parameter checking when strictFunctionTypes is enabled.
The reason this matters is that function consumers control what arguments they pass. When you register a callback with Array<Dog>.map, the runtime will invoke that callback with Dog instances. If the callback signature declares (animal: Animal) => void, TypeScript must verify that every operation inside the callback body is safe for the broader Animal type. Accepting a function that expects Dog would allow code like dog.bark() to execute on a generic Animal, causing a runtime crash.
The implication here is that parameter types are checked in the opposite direction from return types. Return types are covariant—a function returning Dog can substitute for one returning Animal because the caller receives a more specific type than expected, which is always safe. Parameter types are contravariant—the function must accept everything the caller might pass, which means broader types substitute for narrower ones.
%% alt: Contravariant checking ensures parameters are safe for caller's arguments
Before TypeScript 2.6, parameter checking was bivariant—the compiler accepted both directions of assignability for convenience. This allowed patterns like event handlers and array methods to work without type gymnastics, but it also permitted unsafe assignments that could fail at runtime. The strictFunctionTypes flag introduced contravariant checking as an opt-in safety measure. TypeScript 6.0 makes it mandatory by enabling strict mode by default.
The challenge for migration is that codebases written without strict checking often contain hundreds of callbacks that violate contravariance. The compiler suddenly flags these as errors, and teams must decide whether to fix the underlying types or weaken the type system to suppress warnings. The correct choice preserves contravariance and restructures code to match the safety guarantees TypeScript now enforces.
How strictFunctionTypes Changes Function Parameter Checking
The strictFunctionTypes flag alters how TypeScript compares function signatures during assignability checks. Without the flag, the compiler permits bivariant parameter checking—a function expecting Dog can be assigned to a variable typed as (animal: Animal) => void, and vice versa. With the flag enabled, parameter positions enforce strict contravariance for function properties, rejecting assignments where the parameter type is more specific than the target.
Function properties are those declared with the arrow syntax: type Handler = (event: BaseEvent) => void. These enforce contravariance under strict mode. Method signatures use the method syntax: interface Listener { handle(event: BaseEvent): void }. These retain bivariant checking for backward compatibility with classes and object literals. The distinction is critical because identical-looking code behaves differently based solely on syntax.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
// Function property syntax enforces contravariance
type FunctionProperty = (animal: Animal) => void;
// Method signature syntax permits bivariance
interface MethodSignature {
handle(animal: Animal): void;
}
const handleDog = (dog: Dog) => {
console.log(dog.bark());
};
// Error with strictFunctionTypes: Dog not assignable to Animal
const fnProp: FunctionProperty = handleDog;
// Compiles even with strictFunctionTypes: method signatures are bivariant
const methodSig: MethodSignature = { handle: handleDog };
The reason for this split behavior is pragmatic. Classes often override methods with parameters that are more specific than the base class signature, a pattern common in object-oriented hierarchies. Forbidding this would break vast amounts of existing code that relies on covariant overrides. The TypeScript team chose to preserve bivariance for method signatures while enforcing contravariance for function properties, which are primarily used in callback contexts where strict checking prevents bugs.
%% alt: Syntax determines whether TypeScript enforces contravariance or permits bivariance
The migration challenge is that most callback code uses arrow functions and function properties, which means strict mode will flag real issues. Array methods like map, filter, and forEach all accept function properties. Event handlers registered through addEventListener use function properties. Promise chains with .then() callbacks use function properties. Each of these patterns must respect contravariance or refactor to method syntax if bivariance is genuinely needed.
The failure mode here is subtle but expensive. Developers who misunderstand variance rules often respond to strict errors by switching from function properties to method signatures without considering whether bivariance is appropriate. This silences the compiler but reintroduces the unsafety that strict checking was designed to prevent. The correct approach is to fix parameter types first and only use method syntax when bivariance is the intentional design.
Real-World Breaking Changes: Arrays, Event Handlers, and Nested Callbacks
Array methods are the most common source of contravariance errors when migrating to strict mode. Consider a callback passed to Array<Dog>.forEach that declares a parameter of type Animal. Pre-strict TypeScript allowed this because bivariant checking accepted both directions. Strict mode rejects it because the array will invoke the callback with Dog instances, and the callback signature promises to handle Animal, which is too broad.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
const dogs: Dog[] = [
{ name: "Rex", bark: () => console.log("Woof") },
{ name: "Max", bark: () => console.log("Bark") }
];
// Error with strictFunctionTypes:
// Type '(animal: Animal) => void' is not assignable to type '(value: Dog) => void'
dogs.forEach((animal: Animal) => {
console.log(animal.name); // Safe operation
});
The issue is that the forEach signature is (callback: (value: Dog) => void) => void. The callback must accept Dog, but the code declares Animal. Even though the implementation only accesses name, which exists on both types, contravariance requires the parameter type to match or be broader than Dog. The fix is to declare the parameter as Dog or remove the type annotation entirely and rely on inference.
Event handlers follow the same pattern. Developers often create generic event handlers that accept Event but register them to specific event types like MouseEvent or KeyboardEvent. Strict mode flags these as errors because the handler might be invoked with a more specific event type, and the signature does not promise to handle those properties safely.
// Error with strictFunctionTypes:
// Type '(event: Event) => void' is not assignable to type '(event: MouseEvent) => void'
const handleClick: (event: Event) => void = (event) => {
console.log(event.type);
};
document.addEventListener("click", handleClick);
The correct fix is to declare the handler parameter as MouseEvent or use a union type if the handler genuinely needs to support multiple event types. Alternatively, let TypeScript infer the parameter type from the addEventListener signature, which will automatically produce MouseEvent for the "click" event.
Nested callbacks introduce additional complexity because each level of nesting must respect contravariance independently. A Promise chain with multiple .then() calls requires each callback's parameter to match the return type of the previous stage. Widening a parameter type at any stage breaks the chain under strict checking.
interface User {
id: number;
}
interface AdminUser extends User {
permissions: string[];
}
const fetchAdmin = (): Promise<AdminUser> => {
return Promise.resolve({ id: 1, permissions: ["write"] });
};
// Error with strictFunctionTypes:
// Type '(user: User) => void' is not assignable to type '(value: AdminUser) => void'
fetchAdmin()
.then((user: User) => {
console.log(user.id); // Safe operation
});
The failure mode here is that developers see dozens of similar errors and reach for any to silence them. This eliminates type safety entirely and reintroduces the bugs strict checking was designed to catch. The correct approach is to fix each parameter type or use inference, which produces correct types automatically in most cases.
Bivariance vs Contravariance: Why Methods Get Special Treatment
The difference between function properties and method signatures is not cosmetic—it determines whether TypeScript enforces contravariance. Function properties declared with arrow syntax (type Handler = (x: T) => void) enforce strict contravariant parameter checking. Method signatures declared in interfaces or types (handle(x: T): void) permit bivariant checking, allowing parameters to be narrower or broader than the target signature. This distinction exists to preserve compatibility with object-oriented patterns while enforcing safety in callback contexts.
Method syntax bivariance is intentional. Classes frequently override methods with parameters that are more specific than the base class signature, a pattern called covariant method overrides. TypeScript permits this for method signatures to avoid breaking existing class hierarchies, even though it technically violates Liskov substitution. The assumption is that methods are called through object instances where the caller controls the type context, reducing the risk of runtime failures.
class AnimalHandler {
handle(animal: Animal): void {
console.log(animal.name);
}
}
class DogHandler extends AnimalHandler {
// Allowed: method signatures permit covariant overrides
handle(dog: Dog): void {
console.log(dog.bark());
}
}
Function properties, by contrast, are primarily used as callbacks where the consumer controls what arguments are passed. Array methods, event handlers, and promise chains all use function properties. Enforcing contravariance in these contexts prevents type errors where a callback expects a derived type but receives a base type at runtime.
%% alt: Function properties enforce contravariance while method signatures permit bivariance
The migration challenge is that developers often switch from function property syntax to method syntax to silence strict errors without considering whether bivariance is appropriate. If the code truly represents a method that will be called through an object instance, method syntax is correct. If the code represents a callback passed to a higher-order function, switching to method syntax reintroduces unsafety.
The correct approach is to use function property syntax for callbacks and method syntax for methods. This aligns variance behavior with the actual usage pattern. When strict errors occur, fix the parameter types rather than changing the syntax to bypass checking. In the rare case where bivariance is genuinely needed for a callback, explicitly document why and consider whether the design can be refactored to avoid the need.
Migration Strategies: Fixing Your Callbacks Without Losing Type Safety
Migrating to strictFunctionTypes requires a structured approach that fixes parameter types rather than weakening the type system. The first strategy is to widen callback parameters to accept union types or base types that safely cover all cases. When a callback declares Dog but must handle Animal | Cat, change the parameter type to Animal and use type guards inside the function body to narrow when necessary.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
interface Cat extends Animal {
meow(): void;
}
// Before: overly specific parameter
const handlePet = (dog: Dog) => {
console.log(dog.bark());
};
// After: widened parameter with type guard
const handlePetFixed = (animal: Animal) => {
if ("bark" in animal) {
console.log(animal.bark());
} else if ("meow" in animal) {
console.log(animal.meow());
}
};
const pets: Animal[] = [
{ name: "Rex", bark: () => console.log("Woof") } as Dog,
{ name: "Whiskers", meow: () => console.log("Meow") } as Cat
];
pets.forEach(handlePetFixed); // Compiles under strict mode
%% alt: Widening parameters and using type guards preserves contravariance safety
The second strategy is to rely on type inference rather than explicit annotations. TypeScript infers callback parameter types from the consumer's signature, which automatically produces contravariant-safe types. Array methods, event handlers, and promise chains all benefit from inference—removing explicit type annotations often resolves strict errors without requiring code changes.
// Before: explicit annotation causes strict error
dogs.forEach((animal: Animal) => {
console.log(animal.name);
});
// After: inference produces correct type
dogs.forEach((dog) => {
console.log(dog.name); // dog is inferred as Dog
});
The third strategy is to introduce generic constraints that preserve assignability. When a higher-order function accepts a callback with a generic parameter, constrain the generic to the base type and let consumers pass more specific types safely. This maintains strict checking while allowing flexibility at call sites.
function processPets<T extends Animal>(pets: T[], handler: (pet: T) => void): void {
pets.forEach(handler);
}
// Compiles: generic T is constrained to Animal but preserves Dog
processPets(dogs, (dog) => {
console.log(dog.bark()); // dog is inferred as Dog
});
The failure mode here is reaching for any or type assertions to silence errors. Both eliminate type safety and reintroduce the runtime bugs strict checking was designed to prevent. The correct approach is to restructure types or use method syntax only when bivariance is genuinely needed, not as a workaround for strict errors.
When to Use Function Properties vs Method Signatures
The choice between function property syntax and method signature syntax determines whether TypeScript enforces contravariance. Use function properties (type Callback = (x: T) => void) for callbacks passed to higher-order functions, event handlers, and promise chains. Use method signatures (interface Listener { handle(x: T): void }) for methods called through object instances, especially in class hierarchies where covariant overrides are intentional.
Function properties enforce strict contravariant checking, which prevents type errors where a callback expects a derived type but receives a base type at runtime. This is the correct choice for most callback contexts because the consumer controls what arguments are passed. Array methods like map and forEach, event listeners registered through addEventListener, and asynchronous workflows with .then() all use function properties internally and benefit from strict checking.
// Correct: function property enforces contravariance for callbacks
type EventHandler = (event: Event) => void;
const handler: EventHandler = (event) => {
console.log(event.type); // Safe operation on base Event
};
document.addEventListener("click", handler); // Compiles under strict mode
%% alt: Syntax choice aligns variance behavior with usage pattern
Method signatures permit bivariant checking, which allows methods to override base class signatures with more specific parameters. This is appropriate for object-oriented patterns where methods are called through instances and the caller controls the type context. The risk of runtime errors is lower because the method receiver's type determines what operations are safe.
// Correct: method signature permits covariant override
interface Logger {
log(message: string): void;
}
class ErrorLogger implements Logger {
// Allowed: method signature permits bivariance
log(error: Error): void {
console.error(error.message);
}
}
The migration challenge is that developers often switch from function properties to method signatures to silence strict errors without evaluating whether bivariance is appropriate. If the code represents a callback, this reintroduces unsafety. If the code represents a method, the change is correct. The distinction matters because the syntax choice communicates intent—function properties signal callback contexts, method signatures signal object methods.
The correct approach is to default to function property syntax for all callback code and switch to method syntax only when the code genuinely represents a method in a class or interface. When strict errors occur, fix the parameter types first. Only use method syntax as a deliberate design choice for object-oriented patterns, not as a workaround for type errors. This aligns variance behavior with the actual usage pattern and preserves the safety guarantees strict mode provides.
For related type safety patterns, see TypeScript Satisfies Advanced Patterns 2026 for ensuring type correctness without losing inference, TypeScript Generic Constraints Extends Keyof for constraining callback parameters, and TypeScript Form Validators Custom for validating input types in callback contexts.
Frequently Asked Questions
What is contravariance in TypeScript and why does it matter for function parameters?
Contravariance is the rule that function parameters must accept types that are the same or more general than the declared signature. When a callback expects Dog, you can safely pass a function that accepts Animal because the function body will handle all properties of Animal, which includes Dog. TypeScript enforces this with strictFunctionTypes to prevent runtime crashes where a callback expects specific properties that do not exist on the passed argument.
Why do method signatures permit bivariance while function properties enforce contravariance?
Method signatures use bivariant checking to preserve compatibility with object-oriented patterns where classes override methods with more specific parameter types. Function properties enforce contravariance because they are primarily used as callbacks where the consumer controls what arguments are passed, making strict checking necessary to prevent type errors. The syntax choice determines which variance rule applies—arrow syntax enforces contravariance, method syntax permits bivariance.
How do I fix strict function type errors without using any or disabling strict mode?
Widen callback parameters to accept the base type or a union type that safely covers all cases, then use type guards inside the function body to narrow when necessary. Alternatively, remove explicit type annotations and rely on TypeScript's inference, which automatically produces contravariant-safe types from the consumer's signature. For higher-order functions, introduce generic constraints that preserve assignability without requiring specific types.
When should I use method syntax instead of function property syntax for callbacks?
Use method syntax only when the code genuinely represents a method called through an object instance, especially in class hierarchies where covariant method overrides are intentional. Use function property syntax for callbacks passed to higher-order functions, event handlers, and promise chains where contravariance prevents type errors. Switching to method syntax to silence strict errors without evaluating appropriateness reintroces the unsafety strict mode was designed to prevent.
Does TypeScript 6.0 force all existing codebases to fix contravariance errors immediately?
TypeScript 6.0 enables strict mode by default, which includes strictFunctionTypes. Codebases that never configured strict checking will see contravariance errors when upgrading. Teams can temporarily disable strictFunctionTypes in tsconfig.json to defer migration, but the ecosystem increasingly assumes strict mode. The correct long-term approach is to fix parameter types systematically rather than weakening the type system, as strict checking eliminates an entire class of runtime bugs.
Conclusion: Embracing Contravariance in TypeScript 6.0
That covers the essential patterns for migrating to strictFunctionTypes in TypeScript 6.0. The distinction between function properties and method signatures is critical—one enforces contravariance to prevent callback errors, the other permits bivariance for object-oriented compatibility. Apply these patterns in production and the difference will be immediate: strict mode eliminates runtime crashes where callbacks expect derived types but receive base types, while bivariant method syntax preserves flexibility where it is genuinely needed. The migration cost is front-loaded but the safety gains compound over every release.







Top comments (0)