Typescript: Function-like recursive generic interface invariant in its parameter type

Created on 20 Dec 2019  路  4Comments  路  Source: microsoft/TypeScript

I'm aware that this is likely to be a design limitation; I can't find an existing issue about this specific problem so I'm filing this one for reference.

TypeScript Version: 3.8.0-dev.20191220

Search Terms: variance, composition

Code

interface Fn<A, B> {
    (a: A): B;
    then<C>(next: Fn<B, C>): Fn<A, C>;
}

declare const fn: Fn<string, number>;
const contravariantInA: Fn<"a", number> = fn; // error!
// Type 'Fn<string, number>' is not assignable to type 'Fn<"a", number>'.
// Type 'string' is not assignable to type '"a"'.

const covariantInB: Fn<string, unknown> = fn; // okay

Expected behavior: Fn<A, B> should be contravariant in A.

Actual behavior: Fn<A, B> is invariant in A.

I came upon this while investigating a Stack Overflow question in which someone had built a composable type like Fn<A, B> and had to use a workaround to allow the then() method to accept something with a wider input.

@jack-williams suggests that the issue happens because the checker defaults to covariance for A when checking it (in typeArgumentsRelatedTo())... and combined with its contravariant use in the call signature, this results in invariance.

A full structural check might not have this problem but I imagine it might be hard to guarantee that such a check would terminate? (e.g., Fn<A, B> depends on Fn<B, C> which depends on Fn<C, D> which depends on Fn<D, E> etc?)

If there's no plausible way for the compiler to correctly infer the variance here, perhaps this is just another request for variance annotations as per #1394?

Not sure. Anyway, thanks!

Playground Link: Provided

Related Issues:

1394: let us mark the parameters as covariant/contravariant/invariant ourselves

32674: a different variance issue

33872: a different variance bug

Bug Fix Available

Most helpful comment

See #36261 for an improved method of measuring variance.

All 4 comments

@ahejlsberg thoughts?

First off, looks like we measure Fn<A, B> as invariant in A and covariant in B:

declare const fn: Fn<string, number>;

// Invariant in A
const fn1: Fn<unknown, number> = fn;  // Error
const fn2: Fn<'a', number> = fn;  // Error

// Covariant in B
const fn3: Fn<string, unknown> = fn;  // Ok
const fn4: Fn<string, 0> = fn;  // Error

This is pretty much the expected result. As we attempt to relate the recursive references to Fn in the then method, the covariant default kicks in. The combination of that and the measured contravariance from the reference to A in the call signature gives us invariance for A. Would be nice to do better, but if we completely remove the default-to-covariance recursion guard and structurally compare until the entire variance measurement is complete, we have repro examples that never terminate. So, we'd need something smarter, like perhaps a depth based mechanism that only activates the recursion guard after a few levels.

We could consider variance annotations, but we would then also need logic to verify that type declarations don't violate them.

Oops, I got my variance backwards in the example code... I'll update it

Edit: all right, I updated it.

See #36261 for an improved method of measuring variance.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

nitzantomer picture nitzantomer  路  135Comments

disshishkov picture disshishkov  路  224Comments

jonathandturner picture jonathandturner  路  147Comments

RyanCavanaugh picture RyanCavanaugh  路  205Comments

rbuckton picture rbuckton  路  139Comments