The prevailing answer to this query indicates the existence of a type safety gap stemming from optional parameters, rather than extra parameters, which is acknowledged as a design constraint in TypeScript. For more information, refer to microsoft/TypeScript#13043.
It is initially advisable to examine the concepts of soundness and completeness within a static type system. A sound type system never permits unsafe operations, while a complete type system never disallows safe operations. Creating a decidable type system for JavaScript that fulfills both requirements is deemed impossible; however, TypeScript is at least considered sound, right? And although it cannot be completely sound, does TypeScript only restrict potentially unsafe operations? Right? 😬
Surprisingly, TypeScript deliberately permits certain operations that are not
type-safe. There exist vulnerabilities in the type system, allowing code to bypass these gaps and result in runtime errors without any warning from the compiler to safeguard you. The intentional unsoundness of TypeScript's type system persists because patching these holes would likely generate numerous errors in functioning real-world code, rendering the additional safety impractical. It stands as one of the official TypeScript Design Non-Goals so:
Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.
Conversely, there are times when TypeScript intentionally prevents certain type-safe operations. These unnecessary obstacles may lead to scenarios where compiling the code leads to errors despite no runtime issues occurring. TypeScript's deliberate incompleteness manifests itself through linter-like rules that serve as warnings to developers, even if they adhere to fundamental type constraints.
Hence, intentionally unsound and incomplete define the TypeScript type system.
In TypeScript, functions with fewer parameters are deemed compatible with functions possessing more parameters (see relevant handbook doc and FAQ entry) since it typically harmless for a function to receive more arguments than anticipated. Any surplus arguments lacking corresponding parameters are simply disregarded during runtime.
Henceforth, permitting this assignment is regarded as sound:
type F1 = () => void
const f1: F1 = () => console.log("👍");
type F2 = (x: number) => void
const f2: F2 = f1
Given that f1
neglects any input received, calling f2
with an argument poses no issue:
f1(); // "👍"
f2(1); // "👍"
However, why does the compiler prevent this?
f1(1); // compiler error, Expected 0 arguments, but got 1.
This restriction falls under intentional incompleteness. Knowing that function f1()
discards any inputs it may receive, passing in an argument is likely a developer oversight. While I sought official confirmation of this rationale through documentation and GitHub issues, its apparent obviousness may render such elucidation redundant; perhaps, nobody aims to deliberately invoke a function directly with superfluous arguments. (Please share any reliable sources corroborating this insight.)
If indeed users opt against such actions, why does the aforementioned assignment qualify as sound? Essentially, individuals desire to execute tasks like:
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); // "👍" "👍" "👍"
While direct invocation of the callback with excess arguments remains off limits, forEach()
's implementation accomplishes this task seamlessly, signifying it isn't problematic.
The alternative method would require:
arr.forEach((val, ind, arr) => f2(val)) // "👍" "👍" "👍"
arr.forEach((val, ind, arr) => f1()) // "👍" "👍" "👍"
which proves cumbersome (as per documented reasoning behind this rule).
Thus, this permitted assignment proves beneficial and sound, while direct calls are prohibited due to being ultimately futile though still sound.
When assessing compatibility between functions in TypeScript, interchangeable status applies to optional and required parameters. Extra optional parameters hailing from the source type do not trigger an error (relevant handbook doc). This flexibility enables universally treating optional parameters akin to missing ones, provvided operations pertain solely to direct function execution:
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
Sadly, this approach lacks soundness, as scenario below ought to be type-safe:
[1, 2, 3].forEach(f1) // 💥 RUNTIME ERROR!
// x.toUpperCase is not a function
Herein lies a loophole in the type system. Provided operation transpires directly, the compiler impedes inadvertent miscues:
[1, 2, 3].forEach(f0) // compiler error
// ~~ <-- number not assignable to string
Hence, as unearthed, TypeScript's assignability does not demonstrate transitivity (referenced via microsoft/TypeScript#47499 and cited follow-up issues), subsequently undermining the overall soundness of the type system.
This conundrum emanates from conflating "optional" and "missing," perpetually witnessed across various scenarios. Optional properties reflect identical loopholes, expounded in this microsoft/TypeScript#47331 comment, essentially manifesting in situations like {x: number} extends {}
accurately allowed, whereas {x: number} extends {x?: string}
rightfully meets rejection. However, {} extends {x?: string}
encounters undue allowance albeit immensely useful.
To recap, 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 ranks as sound and acceptable:
f2 = f1; // sound, true negative
Contrarily, this assignment deviates into unsound territory yet warrants approval:
f1 = f0; // unsound, false negative
Mixing both scenarios delves us into unforeseen complications culminating in runtime errors.
Playground link to code