8000 Narrowing call signatures doesn't work so well · Issue #10471 · microsoft/TypeScript · GitHub
[go: up one dir, main page]

Skip to content
Narrowing call signatures doesn't work so well #10471
@yortus

Description

@yortus

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design LimitationConstraints of the existing architecture prevent this from being fixedSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      0