-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Type hole with compatibility between optional parameters/extra parameters #13043
Comments
You've identified the problem and why there isn't a solution -- preventing either assignment is problematic. TypeScript does not guarantee soundness and this is one of the holes. Even Flow, which generally tries to be more sound at the expense of convenience, allows this (update: I see you just logged this on their tracker as well). |
If you somehow find yourself in this situation and need to track down how the wrong function got assigned in, you can declare out the optional parameter in the nullary case assuming you can figure out which call signature originated the problem in the first place: // Error
const y: (_?: "must be missing!") => number = x; |
@RyanCavanaugh Yeah Flow has the same problem (facebook/flow#3049), but I found it more surprising considering they have more of an objective of soundness than TS does. If this is just another baked-in gotcha then fair enough, I'll add it to the list. |
@RyanCavanaugh I posted a solution against the Flow issue which you may be interested in, which I'll repeat here. Basically you store against a function signature whether or not extra parameter slots should accept anything For function literals, the inferred type has any extra parameters set to accept anything, since the function ignores those parameters. This way you can pass callbacks or implement interfaces without specifying all the necessary parameters, eg For function types written directly, the type has any extra parameters set to accept only The only restriction is that a written function type is not compatible with the same written function type with an extra parameter, because the shorter function type could be masking a function type with additional optional parameters for which the only known acceptable input is Example: const x = (a: number = 1): number => a;
// x: (number|undefined, {}...) => number
const y: () => number = x;
// y: (undefined...) => number
// Assignment OK, undefined is compatible with both number|undefined and {}
const z: (a: string) => number = y;
// z: (string, undefined...) => number
// ^ Error on assignment, string is not compatible with undefined
const w = (): number => 1;
// w: ({}...) => number
const v: (a: string) => number = w;
// v: (string, undefined...) => number
// ^ Assignment OK, string and undefined are both compatible with {} |
Passing extra arguments should always be allowed. This is just the nature of JS. However a much easier fix, that catches the problem at its root is disallowing const x = (a: number = 1): number => a;
const y: () => number = x; If Optional aliasing should be much more easily detected ( |
@gcnew I think being able to override class methods and add extra optional parameters is fairly reasonable and expected behaviour, which means |
I see now where you are coming from. Unfortunately overloads and overrides are one and the same thing in JavaScript. This makes your proposal better for class hierarchies. On the other hand JavaScript is traditionally used in functional style. As such I'm not in favour of hindering virtually every callback argument. I still think that conditionally outlawing unsafe optional param assignments is the better alternative. If we go with your proposal then |
I don't see what overloading has to do with anything.
Can you give me an example? You can still omit parameters when passing callbacks.
The only purpose
It's just capturing the knowledge that extra parameters will be ignored by the function and they will therefore accept |
I don't think so. The runtime behaviour / expectations are different. You can't safely use such a function in all contexts the original can be used, precisely because JavaScript functions can be called with extra parameters by definition. It is also not supported in languages such as C# because the type is different although compatible at times.
Even if we agreed on the above, then comes the problem that you should be able to add overloads in subclasses. Overloads in JS are basically just one function that handles all the cases. This way it clashes with overriding, because banning defaulting overrides has the side effect of banning overloads as well.
Sure - every named function. const const42 = () => 42;
[1, 2, 3].reduce(const42, 0) Now, you'd say that the inferred signature for One remedy is declaration files / module interfaces to include extra param information, but I don't see this happening. Also if a user provided signature is present, it should not be silently expanded, but rather taken as is. This makes adding With the current state of affairs I don't see how the situation can be improved on, without sacrificing valid expressiveness power. As both approaches have their respective flaws, I think the best way forward is adding linter rules for both and allowing programmers to choose their poison. |
Of course JavaScript allows a TypeScript function's signature to be disobeyed. JavaScript lets you pass TypeScript also statically ensures that a function is called without extra parameters and that their runtime value is therefore
I don't know what you mean by "defaulting overrides". You can certainly use overriding, extra optional parameters, and overloading all at the same time, right now, in a type safe way, and my suggestion wouldn't change that. class Foo {
foo(x: string): void { }
}
class Blah implements Foo {
// Foo.foo just has to be compatible with at least one of these
foo(x: string, y?: number): void;
foo(x: number): void;
foo(x: Promise<number>): void;
foo(x: string | Promise<number> | number, y: number = 1) {
// Test x and y to determine what overload was used
}
}
let b = new Blah();
b.foo('x');//ok
b.foo(1);//ok
b.foo(Promise.resolve(1));//ok
b.foo(Promise.resolve(1), 1);// not ok, doesn't match any signatures
You could pass Having the effective |
I truly want to disambiguate the matter, maybe I'm not expressing myself well. I'm not bringing in the argument that you can violate type expectations via JS. While there is nothing stopping you from doing it, programmers don't do it in practice because even if the type is not explicit it is still there and not abiding to it results in a runtime error. On the "statically ensures" part is where see different approaches. While it is true that you are not allowed to pass extra arguments on purpose it is often implicitly the case with callbacks. Now there are two ways forward (as you've already stated in the description of the issue). I think the approach you propose is hardly an ideal one. Why?
These reasons are not all true at the same time, rather they are possible consequences of the tradeoffs involved in an actual implementation. Alternatively, I see the issue from the other side. From my point of view a My proposal (which is not actually mine, you've mentioned it in the description) embraces the idea that extra arguments will be passed and warns you to use a safer pattern in the case of defaulting. The negatives are that overloading and overriding will be broken. I can live with that as I don't use classes and even less so class hierarchies. Having said that, I do agree it's not acceptable for everyone just as it's not acceptable for me to add boilerplate to my perfectly valid function signatures and usage. In the end of the day it all boils down to: either functions with lower arity are not assignable to references with higher (your proposal) or functions with extra defaulted parameters are not assignable to references without them. I support the second option, because I admit calling a function with more arguments and it also rules out the uncertainty introduced by runtime defaulting. |
Discussed for a while at the backlog slog. Some more detailed notes
So if anyone wants to see this happen, we'll need a PR we can run against our internal code suite to see what the real-world impact is and assess value vs breakage. |
Just thinking out loud, something that's going to really problematic is class methods. The "could there be extra parameters" bit would have to be set for class methods, because a derived class could declare new optional parameters, but it's entirely reasonable to write code like this and not expect an error: class Base {
bar = (n: number) => { console.log(n); }
foo() {
// Error, but seems ok...
[1, 2, 3].forEach(this.bar);
}
// Maybe you have to write this instead?? Super annoying?
bar = (n: number, _1: may_not_use, _2: may_not_use) => { console.log(n); }
}
// Theoretically-possible problem case
// that people will complain that they didn't do and shouldn't be bothered about
class Derived extends Base {
bar = (n: number, s?: string) => { console.log(n); }
} |
Is this related to this problem? I've noticed I can't create a function overload that makes a parameter optional by way of omission in the parameter list. Normally making the trailing parameters optional is a good thing for concise code, but if the overloads have different return types, making a trailing parameter optional just to resolve compatibility seems kind of hacky. export type CurriedHasFn<K, V> = (map: HashMap<K, V>) => boolean;
export function has<K, V>(key: K): CurriedHasFn<K, V>;
export function has<K, V>(key: K, map?: HashMap<K, V>): boolean;
export function has<K, V>(key: K, map?: HashMapImpl<K, V>): boolean|CurriedHasFn<K, V> {
// ...
} In the above snippet, I'm forced to make the second parameter of the the second overload optional, otherwise I can't get it to compile without compatibility issues. I would think that the absence of the second parameter in the first overload should satisfy optionality in the internal/final overload, and that the presence of the parameter therein should be enough to satisfy a non-optional version of the parameter in the second overload. This is what I actually want: export type CurriedHasFn<K, V> = (map: HashMap<K, V>) => boolean;
export function has<K, V>(key: K): CurriedHasFn<K, V>;
export function has<K, V>(key: K, map: HashMap<K, V>): boolean; // non-optional `map`
export function has<K, V>(key: K, map?: HashMapImpl<K, V>): boolean|CurriedHasFn<K, V> {
// ...
} |
@axefrog I'm confused by your example - in the first block, why is the second overload specifying |
@RyanCavanaugh Because I get red squigglies otherwise. Evidence below: |
I suspect it is because the type of the map in the overload declaration is |
This issue seems to have fixed. |
This doesn't seem to be an issue in practice, and our appetite for breaking changes diminishes every day. |
FWIW just as a data point, this unsoundness has been complicit in a production outage at Google. Authors expected type safety in a refactoring that changed the type of an optional argument (e.g. from It's a single data point, so not necessarily something that should change the judgement on this particular issue. I just thought I'd leave this here for future reference, in case it comes up again. |
TypeScript Version: Whatever runs https://www.typescriptlang.org/play/
Code
Expected behavior:
TypeScript error that either
(a?: number) => number
is incompatible with() => number
, or that() => number
is incompatible with(a: string) => number
. I'm not sure which one is most correct, but permitting both of these things in combination is definitely unsound.Actual behavior:
No TypeScript error on the
const z: (a: string) => number = y;
line nor thez('x').toFixed();
line., but an error when the code runs.The text was updated successfully, but these errors were encountered: