Execute Program

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 type string. Literal strings are compatible with string because literal string types are a "kind of" string.

  • >
    const s1: 'lastLoginDate' = 'lastLoginDate';
    const s2: string = s1;
    s2;
    Result:
    'lastLoginDate'Pass Icon
  • A tuple type like [number, number] is compatible with the array type Array<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]Pass Icon
  • We can come up with infinitely many type compatibility examples. An Array<'lastLoginDate'> is assignable to a variable of the type Array<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 of string, 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 type string. Does that mean that the function type (s: 'lastLoginDate') => string is 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"'.Pass Icon
  • 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 takesLiteralString function 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 TakesString type, 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 original takesLiteralString function'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"'.Pass Icon
  • What about the opposite case? Can we assign a function (s: string) => string to 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'Pass Icon
  • In this case, everything is fine. Our testFunction variable only lets us pass the literal string 'lastLoginDate'. But the underlying takesString function can take 'lastLoginDate' or 'Amir' or any other string. By assigning an (s: string) => string function to an (s: 'lastLoginDate') => string variable, 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 a string variable 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 a string. It's also fine to pass a [number, number] to a function that expects an Array<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 any Array<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.Pass Icon
  • 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 any to 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 used any to silence the type error.

  • We'll pass [1] to the function that expects a tuple of two elements. When it does numbers[0] + numbers[1], that becomes 1 + undefined, which gives NaN in JavaScript and TypeScript.

  • (NaN means "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:
    NaNPass Icon
  • 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 strictFunctionTypes option. 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": true in your tsconfig.json file, which will enable strictFunctionTypes and a few other safety options.