21.4 strictFunctionTypes: Correct Function Variance
Now, let’s talk about the one that separates the TypeScript novices from the greybeards: strictFunctionTypes. This flag is the bouncer at the type-safe club, and it’s checking your function’s ID to make sure it’s not lying about its age. It enforces correct variance for function types, which is a fancy way of saying it makes sure your function parameters behave in a way that won’t blow up at runtime.
Here’s the core of the issue. TypeScript uses structural typing (if it walks like a duck and talks like a duck, it’s a duck), but when it comes to function parameters, the safe thing to do is actually the opposite. It’s called contravariance. Hold on, don’t click away. I’ll make this painless.
Imagine a function that can handle a Dog.
interface Dog {
breed: string;
}
function walkDog(dog: Dog) {
console.log(`Walking ${dog.breed}`);
}
Under normal, non-strict rules, TypeScript would say it’s perfectly fine to assign a function that expects a more specific Bulldog to a variable that expects a function for any Dog. This is called covariance, and it’s a recipe for disaster.
interface Bulldog extends Dog {
droolLevel: number;
}
// A function that specifically needs a Bulldog (because it accesses a Bulldog property)
const walkBulldog = (bulldog: Bulldog) => {
console.log(`Walking ${bulldog.breed}. Bring a towel for the ${bulldog.droolLevel}/10 drool.`);
};
// ❌ Without strictFunctionTypes, this is allowed. This is BAD.
let walker: (dog: Dog) => void = walkBulldog;
// Now we call the function with a plain Dog that DOESN'T have a 'droolLevel'
const myMutt: Dog = { breed: "Mutt" };
walker(myMutt); // Runtime Error: Cannot read property 'droolLevel' of undefined 🤮
See what happened? We ended up with a function that expected a Bulldog but we fed it a regular Dog. It tries to access .droolLevel and the whole thing explodes. This is the exact scenario strictFunctionTypes is designed to prevent.
How It Actually Works: Contravariance
strictFunctionTypes fixes this by enforcing contravariance on function parameter types. This just means that when comparing two functions, the parameter types of the target function (the one you’re assigning to) must be less specific than the parameter types of the source function (the one you’re assigning from).
In safe, sane code, you should only be able to assign a function that expects Bulldog to a variable that expects a function for Dog if the function itself can handle any Dog. The only way to do that is to make the parameter less specific.
This is why the correct, type-safe assignment is the reverse:
// A function that can handle ANY Dog
const walkAnyDog = (dog: Dog) => {
console.log(`Walking ${dog.breed}`);
};
// ✅ This is ALWAYS safe. We're assigning a function that handles a general type (Dog)
// to a variable that expects a function for a more specific type (Bulldog).
// The function `walkAnyDog` might not use Bulldog properties, but it won't crash.
const walker: (bulldog: Bulldog) => void = walkAnyDog;
// This works perfectly.
const myBulldog: Bulldog = { breed: "Bulldog", droolLevel: 11 };
walker(myBulldog);
The function walkAnyDog promises to handle any Dog. A Bulldog is a Dog, so it fulfills the contract perfectly. This is contravariance in action, and it’s the only way to guarantee runtime safety.
The Method Exception (Because Of Course There Is)
Now, here’s the part where the TypeScript designers decided to throw a practicality grenade into our beautiful theory bunker. For methods (functions defined inside an object or class), the compiler relaxes this strict rule and uses bivariance (either covariant or contravariant) by default, even under strictFunctionTypes.
interface Comparison<T> {
compare: (a: T, b: T) => number; // Function syntax - CHECKED strictly
compareBivariant(a: T, b: T): number; // Method syntax - NOT checked strictly
}
let contra: Comparison<Dog>['compare'] = (a: Bulldog, b: Bulldog) => { ... }; // ❌ Error! Good.
let bi: Comparison<Dog>['compareBivariant'] = (a: Bulldog, b: Bulldog) => { ... }; // ✅ No Error. Sigh.
Why this madness? Mostly for historical reasons and the fact that a ton of existing JavaScript code, particularly array methods like Array<Dog>.forEach((item: Bulldog) => ...), relied on this unsound behavior. It was deemed too big a breaking change to enforce strictness everywhere. It’s a trade-off, and you should consider it a code smell. Prefer the function property syntax (a: T) => void over the method syntax method(a: T): void when you want to ensure maximum type safety.
Best Practices and Pitfalls
- Always Enable It: This should be non-negotiable. The soundness it provides is a cornerstone of writing robust TypeScript. The “pitfall” is not using it.
- Understand the Method Loophole: Be aware that the method syntax is a escape hatch from this safety. Use it intentionally, not by accident.
- Function Syntax for Safety: When defining interfaces for function properties (like callbacks in libraries), use the property syntax
callback: (value: T) => voidto get the full benefit ofstrictFunctionTypes. - It’s About Parameters: Remember, this only affects function parameters. Return types are still checked covariantly (which is safe), and property types are checked invariantly.
Enabling strictFunctionTypes is like finally getting a compiler that tells you, “Hey, that thing you’re about to do? It’s going to fail spectacularly at 3 AM on a Saturday.” It’s one of the best friends you’ll have in your codebase, even if its honesty can be a little brutal.