Advanced TypeScript: Function Parameter Type Compatibility
Welcome to the Function Parameter Type Compatibility lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
Every static language needs rules for type compatibility, governing which types can be assigned to which other types. For example, TypeScript will allow us to assign a literal string type like
'lastLoginDate'to a variable with the typestring. Literal strings are compatible withstringbecause literal string types are a "kind of" string.>
const s1: 'lastLoginDate' = 'lastLoginDate';const s2: string = s1;s2;Result:
'lastLoginDate'
A tuple type like
[number, number]is compatible with the array typeArray<number>. This makes intuitive sense because the tuple value[1, 2]is also an array of numbers. The tuple just happens to have a length of exactly 2, whereas arrays can be of any length.>
const a1: [number, number] = [1, 2];const a2: Array<number> = a1;a2;Result:
[1, 2]
We can come up with infinitely many type compatibility examples. An
Array<'lastLoginDate'>is assignable to a variable of the typeArray<string>. An object of the type{name: string}is assignable to a variable of the type{name: string | number}. And so on.Those examples all show the normal compatibility rule in TypeScript: narrower types are assignable to wider types. As we work with TypeScript, we build an intuitive sense for how this type compatibility works. We understand that
'lastLoginDate'is a kind ofstring, even though we haven't seen the code inside the compiler that makes that work.This lesson covers one place where everyone's intuitive sense breaks down, at least at first: function type compatibility. TypeScript's designers could have adopted the most obvious rule: "functions are compatible when they take exactly the same parameter types and have exactly the same return type". They didn't do that, and for good reasons.
Let's begin by extending our literal string example above. We know that the literal type
'lastLoginDate'is assignable to the wider typestring. Does that mean that the function type(s: 'lastLoginDate') => stringis assignable to the wider type(s: string) => string? Take a guess below. Or, if you find it interesting, try to work out the answer yourself by coming up with some specific examples to analyze.>
function takesLiteralString(s: 'lastLoginDate'): string {return s;}type TakesString = (s: string) => string;const testFunction: TakesString = takesLiteralString;testFunction('lastLoginDate');Result:
type error: Type '(s: "lastLoginDate") => string' is not assignable to type 'TakesString'. Types of parameters 's' and 's' are incompatible. Type 'string' is not assignable to type '"lastLoginDate"'.Functions that take narrower types are not assignable to functions that take wider types. This is different from how type compatibility works in the rest of TypeScript!
Let's imagine that TypeScript did allow the code above. What would happen? The
takesLiteralStringfunction can only take'lastLoginDate'as its argument. If we try to pass any other string, that violates the types.We're assigning that function to a variable with the
TakesStringtype, which allows any string as an argument. If TypeScript allowed this assignment, we'd be able to call our function with any string, violating the originaltakesLiteralStringfunction's type!>
function takesLiteralString(s: 'lastLoginDate'): string {return s;}type TakesString = (s: string) => string;const testFunction: TakesString = takesLiteralString;testFunction("this string doesn't match the literal string type");Result:
type error: Type '(s: "lastLoginDate") => string' is not assignable to type 'TakesString'. Types of parameters 's' and 's' are incompatible. Type 'string' is not assignable to type '"lastLoginDate"'.What about the opposite case? Can we assign a function
(s: string) => stringto the type(s: 'lastLoginDate') => string?>
type TakesLiteralString = (s: 'lastLoginDate') => string;function takesString(s: string): string {return s;}const testFunction: TakesLiteralString = takesString;testFunction('lastLoginDate');Result:
'lastLoginDate'
In this case, everything is fine. Our
testFunctionvariable only lets us pass the literal string'lastLoginDate'. But the underlyingtakesStringfunction can take'lastLoginDate'or'Amir'or any other string. By assigning an(s: string) => stringfunction to an(s: 'lastLoginDate') => stringvariable, we're allowing a narrower set of arguments, which is safe.Here's a way to think about all of this. For regular, non-function types, we can always assign narrower types to wider types. We can assign the literal type
'lastLoginDate'to astringvariable because the former has a narrower type.For function parameters, the same rule applies, but in reverse. We can assign functions that take wider parameters to function types that take narrower parameters. Those assignments constrain the types of arguments that we can pass, which is safe. It's fine to pass a
'lastLoginDate'to a function that expects astring. It's also fine to pass a[number, number]to a function that expects anArray<number>.Suppose that this rule didn't exist, and TypeScript allowed us to treat functions that take narrower types as if they actually took wider types. We could write a function that only works for two-element arrays like
[1, 2]and[500, 600]. But then we could store that function in a variable whose type says "I can take anyArray<number>, no matter how many elements it has". That would be a bug: it would allow us to pass[1, 2, 3]to our function, violating its original type.>
type TakesManyNumbers = (numbers: Array<number>) => number;function sumTwo(numbers: [number, number]): number {return numbers[0] + numbers[1];}const sum: TakesManyNumbers = sumTwo;sum([1]);Result:
type error: Type '(numbers: [number, number]) => number' is not assignable to type 'TakesManyNumbers'. Types of parameters 'numbers' and 'numbers' are incompatible. Type 'number[]' is not assignable to type '[number, number]'. Target requires 2 element(s) but source may have fewer.That error message is especially nice: it says exactly what the problem is. "Target requires 2 element(s) but source may have fewer."
To prove that TypeScript is preventing a bug, let's use
anyto defeat the type system and force TypeScript to let us make the mistake. The next example is identical to the previous, except that we've usedanyto silence the type error.We'll pass
[1]to the function that expects a tuple of two elements. When it doesnumbers[0] + numbers[1], that becomes1 + undefined, which givesNaNin JavaScript and TypeScript.(
NaNmeans "not a number". It's the value that JavaScript gives us when we use mathematical operators on values that don't make sense.)>
type TakesManyNumbers = (numbers: Array<number>) => number;function sumTwo(numbers: [number, number]): number {return numbers[0] + numbers[1];}const sum: TakesManyNumbers = sumTwo as any;sum([1]);Result:
NaN
That shows us why TypeScript has this seemingly-weird rule, with "reversed" type compatibility for parameters. It reflects the way that functions actually work at runtime! In fact, this is how functions work in every programming language, including dynamic languages like JavaScript that don't even have a type system. Experienced programmers often understand this intuitively, even if they don't know a static language, and even if it feels weird when we write the rule down in English like we've done here.
The technical term for what we've seen here is "contravariance". In most strict static languages, function types are "contravariant on the parameter types". That means "for function parameters, the normal rule about assigning narrower types to wider types is reversed".
You're likely to forget the term "contravariance". In fact, you may forget the details of the rule itself! It just doesn't fit well in our minds for some reason, which makes this topic infamous. The most important thing to remember is "the type rules for function arguments are weird". Or, if you can manage it, "the type rules for function arguments are reversed". You can always carefully think through a type error step by step, as long as you remember that the rules here are different.
Finally, in an earlier lesson on compiler options we mentioned the
strictFunctionTypesoption. That option is enabled in all Execute Program examples. Disabling it will loosen function parameter type enforcement, allowing bugs that would be prevented otherwise. As mentioned in the compiler options lesson, we recommend always enabling"strict": truein yourtsconfig.jsonfile, which will enablestrictFunctionTypesand a few other safety options.