-
Notifications
You must be signed in to change notification settings - Fork 12.9k
Description
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
andBar
are clearly unrelated, looking at their declarations. Their relative arities don't imply a relationship. There is certainly no expectation that aBar
can be substituted wherever aFoo
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'
orfn 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
, treatBar
as unrelated, so in theif
clausefn
isFoo
, and in theelse
clausefn
isBar
.
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.