Skip to content
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

Narrowing call signatures doesn't work so well #10471

Closed
yortus opened this issue Aug 22, 2016 · 3 comments
Closed

Narrowing call signatures doesn't work so well #10471

yortus opened this issue Aug 22, 2016 · 3 comments
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed Suggestion An idea for TypeScript

Comments

@yortus
Copy link
Contributor

yortus commented Aug 22, 2016

TL;DR Suggestion: when performing type narrowing, treat all call signatures as subtypes of Function, but unrelated to each other.

First of all, I really appreciate all the great improvements to type analysis and narrowing that have gone into 2.0. A lot of plain code is now very accurately type-checked with no extra annotations or type assertions needed. Big thanks.

But! When it comes to narrowing call signatures, the nightly compiler rejects my runtime-valid code, and I have to engineer subtle workarounds (with a few // don't change this! comments) to keep it happy. I wonder whether an improvement in this area would be considered?

Current Behaviour

// A couple of call signatures...
type Foo = (length: number, width: number) => void;
type Bar = (options: {}) => {};
declare function isFoo(x): x is Foo;

// Let's tell them apart and do some work...
function test(fn: Foo | Bar) {
    if (isFoo(fn)) {
        fn // fn is Foo | Bar
        fn(4, 2); // ERROR: Cannot invoke an expression whose type lacks a call signature
    }
    else {
        fn // fn is never
        fn.length; // ERROR: Property length does not exist on type 'never'
    }
}

Explanation: The compiler treats call signatures as all belonging to an implicit type hierarchy. In the example above, Bar is a subtype of Foo because it has fewer parameters. Due to subtype reduction, when the type guard narrows fn to Foo, the narrowed type keeps Bar as well since it's a subtype of Foo. Conversely, in the else clause, fn can't be a Foo so it can't be a Bar either since that's a subtype of Foo.

The Problem

  • The compiler's analysis does not match runtime behaviour. The code above is correct and works just fine (provided a suitable isFoo implementation).
  • It's also not logical, and breaks programmer expectations. Foo and Bar are clearly unrelated, looking at their declarations. Their relative arities don't imply a relationship. There is certainly no expectation that a Bar can be substituted wherever a Foo is expected, which is what a subtype relationship generally implies (and what justifies the current narrowing behaviour).

Current Workarounds

To remove compiler errors, the programmer must figure out if there are implicit type relationships between the call signatures they are trying to narrow. They can then either:

  • add fake compile-time declarations to break the relationship, or
  • rearrange their type guards to narrow to the most specific type first. In the example above, checking isBar first would work perfectly. But this is not obvious or discoverable.

Suggested Behaviour

When narrowing, treat all call signatures as subtypes of Function, but unrelated to each other. For example:

  • if the type guard is either typeof fn === 'function' or fn instanceof Function, then keep the current behaviour, since the intention is clearly to catch all call signatures regardless of arity or parameter/return types.
  • In the example above, when narrowing to Foo, treat Bar as unrelated, so in the if clause fn is Foo, and in the else clause fn is Bar.

Real-World Examples

Example 1: Express Middleware

Express middleware are functions, and there are two kinds: normal handlers and error handlers. Express tells them apart by arity: error handlers have four parameters, normal handlers have less than four (source code).

If we model this in TypeScript, we get the Foo | Bar situation above which doesn't compile. The suggested behaviour would fix this.

type Middleware = (req, res, next?) => void;
type ErrorMiddleware = (req, res, next, err) => void;

function isErrorMiddleware(fn: Function): fn is ErrorMiddleware {
    return fn.length === 4; // This is actually how express does it
}

function express(fn: Middleware | ErrorMiddleware) {
    if (isErrorMiddleware(fn)) {
        fn // f2 is Middleware | ErrorMiddleware
        // FAIL
    }
    else {
        fn // f2 is never
        // FAIL
    }
}

Example 2: Subclassing Functions in ES6

GeneratorFunction is a builtin subclass of Function. In ES6, it's possible to effectively create our own Function subclasses thanks to Symbol.hasInstance. Here's a gist of how to do it in TypeScript.

We can tell them apart using instanceof, for example:

// ...declarations of MatchFunction and NormalizeFunction...

let fn: MatchFunction | NormalizeFunction;
if (fn instanceof MatchFunction) {...} else {...} // PROBABLY FAIL

However whether narrowing works or gives compiler errors will depend on whether there exists an implicit subtype/supertype relationship between MatchFunction and NormalizeFunction based on their call signatures, which is totally irrelevant. The suggested behaviour would fix this.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Aug 22, 2016
@yortus
Copy link
Contributor Author

yortus commented Aug 25, 2016

The notes in the Backlog Slog say 'design limitation' for this issue. Could anyone from the team elaborate a little? Is this doomed for now?

@RyanCavanaugh
Copy link
Member

Probably doomed. There's no mechanism for the compiler to start treating types differently when they're in unions or as part of narrowing, and because one of the function types is a subtype of the other, we are liable to subtype-reduce the union to the lesser type (same as if you had written HTMLDivElement | HTMLElement, which we sometimes eagerly reduce to HTMLElement).

@RyanCavanaugh
Copy link
Member

The object primitive type now exists, which will prevent collapsing of the Foo | Bar union if used in place of {}. You might also be able to never your way out of this if it comes to that.

@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

2 participants