Search code examples
typescripttypestype-conversiontype-safety

Does allowing extra parameters break type safety in TypeScript?


Consider this example: (playground)

type F0 = (x?: string) => void
type F1 = () => void
type F2 = (x: number) => void

const f0: F0 = (x) => console.log(x, typeof(x))
const f1: F1 = f0
const f2: F2 = f1

f0('s')
f2(1) // f0 outputs 1, 'number', but number is not (string | undefined)

Here, type F0 expects the first parameter x to be string | undefined, so it is assignable to type F1, which takes no parameters. This is acceptable, since calling a function of type F0 would pass an implicit undefined as the first parameter.

In TypeScript, type F1 is assignable to type F2 because extra parameters are allowed. The idea is that a function of type F1 would simply ignore the extra parameters.

However, the problem is that F1 is assignable to F2, and a call to a function of type F2 is incompatible with the expected type in a call to a function of type F0, which is assignable to F1 but not F2. Assigning F0 to F1 seems safe, but assigning F1 to F2 does not seem safe. The code compiles even with strict (and thus strictFunctionTypes) enabled.


Real-world example

This is a real-world example of how it can be a problem:

function processData (optionalData?: DataType) {
  // implementation expects optionalData to be DataType | undefined
}

// somewhere, processData is converted into () => void
const handler: () => void = processData

// somewhere, handler is used on a DOM event handler, which passes an Event object as the first argument, which is not what processData expects
element.addEventListener('click', handler) // bad, but allowed
element.addEventListener('click', () => handler()) // OK

Solution

  • The canonical answer to this question is that you've found a type safety hole (although it's due to optional parameters, not extra parameters), and that this is a design limitation of TypeScript. See microsoft/TypeScript#13043.


    At the outset it's probably best to consider soundness and completeness of a static type system. A type system is sound if it never allows unsafe operations. A type system is complete if it never prohibits safe operations. It is impossible to have a decidable type system for JavaScript that is both sound and complete, but surely TypeScript is at least sound, right? And while it can't also be complete, surely TypeScript only prohibits operations if it can't be sure that they are safe, right? Right? 😬

    Well, as it turns out, TypeScript intentionally allows certain operations that are not type safe. There are holes in the type system, and it's possible to write code that falls through these holes, leading to runtime errors with no compiler warning to protect you. TypeScript's type system is intentionally unsound. The holes are allowed to remain because the TypeScript team thinks that patching them would end up generating a lot of errors on perfectly good real-world code, and the added safety is not considered worth the inconvenience. It is one of the official TypeScript Design Non-Goals to:

    Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.

    On the other hand, sometimes TypeScript intentionally prohibits certain type safe operations. There are technically unnecessary obstacles, and it's possible to write code that runs into these obstacles, leading to compiler errors where no runtime problem would occur. TypeScript's type system is intentionally incomplete. There are essentially linter-like rules that warn the developer that they are probably making a mistake, even if they are not writing code that violates any fundamental type constraints.

    So the type system is unsound and incomplete, on purpose.


    TypeScript considers functions of fewer parameters to be assignable to functions of more parameters (see relevant handbook doc and FAQ entry) since it is almost always harmless for a function to receive more arguments than it expects. Any arguments that don't have a corresponding parameter are just ignored at runtime.

    So it is considered sound to allow this assignment:

    type F1 = () => void
    const f1: F1 = () => console.log("👍");
    
    type F2 = (x: number) => void
    const f2: F2 = f1
    

    After all, f1 ignores any argument it receives, so calling f2 with an argument doesn't hurt anything:

    f1(); // "👍"
    f2(1); // "👍"
    

    But then why does the compiler prohibit this?

    f1(1); // compiler error, Expected 0 arguments, but got 1.
    

    This is one of those intentional incompletnesses. The function f1() is known to ignore any inputs it receives, so it's probably a developer mistake to pass in an argument. I scoured the docs and GitHub issues for some official source for this, but it seems to be so obvious that it doesn't have to be mentioned: probably nobody wants to intentionally call a function directly with too many arguments. (If someone knows a good source for this, let me know.)

    But wait, if nobody wants to do this, why is the assignment above considered sound? Because people do want to do this:

    const arr = [1, 2, 3];
    arr.forEach(x => console.log(x.toFixed(1))); //  "1.0" "2.0" "3.0"    
    arr.forEach(() => console.log("👉")); // "👉" "👉" "👉"
    
    arr.forEach(f2); // "👍" "👍" "👍"
    arr.forEach(f1); // "👍" "👍" "👍" 
    

    You're not directly calling the callback with more arguments than it needs, but the implementation of forEach() is doing that, and it's not a problem.

    The alternative would be requiring you to do this:

    arr.forEach((val, ind, arr) => f2(val)) // "👍" "👍" "👍"
    arr.forEach((val, ind, arr) => f1()) // "👍" "👍" "👍"
    

    which is obnoxious (and the stated reason for this rule in the documentation).

    So the assignment is allowed because it's useful and sound, but the direct call is prohibited because it's useless, although still sound.


    When TypeScript compares functions for compatibility, optional and required parameters are interchangeable. Extra optional parameters of the source type are not an error (relevant handbook doc). This is very useful, because it allows people to treat optional parameters the same as missing parameters, and as long as you're only every directly calling the functions, it's fine:

    type F0 = (x?: string) => void
    const f0: F0 = (x) => console.log(x?.toUpperCase())
    f0('abc') // "ABC"
    
    type F1 = () => void
    const f1: F1 = f0
    f1(); // undefined
    

    But this is unsound, since the following should be type safe:

    [1, 2, 3].forEach(f1) // 💥 RUNTIME ERROR! 
    // x.toUpperCase is not a function
    

    Oops! That's a hole in the type system. The compiler prevents you from making this mistake if you do it directly:

    [1, 2, 3].forEach(f0) // compiler error
    //                ~~ <-- number not assignable to string
    

    So, as you've discovered, assignability in TypeScript is not transitive (see microsoft/TypeScript#47499 and the tree of issues it links to), and therefore the type system is not sound.

    The problem comes up in several places where "optional" and "missing" are conflated. Optional properties have the same hole, as shown in this comment on microsoft/TypeScript#47331, since it is {x: number} extends {} is correctly allowed, and {x: number} extends {x?: string} is correctly rejected, but {} extends {x?: string} is incorrectly (but oh so usefully) allowed.


    In summary, given:

    type F0 = (x?: string) => void
    declare let f0: F0;
    type F1 = () => void
    declare let f1: F1;
    type F2 = (x: number) => void
    declare let f2: F2;
    

    This assignment is sound and allowed:

    f2 = f1; // sound, true negative
    

    while this assignment is unsound but allowed

    f1 = f0; // unsound, false negative    
    

    And when combined, the unsoundness bites us with a runtime error.

    Playground link to code