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

3.5.1 regression when using this with an index signature of a generic union type #31731

Closed
justingrant opened this issue Jun 3, 2019 · 5 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@justingrant
Copy link
Contributor

justingrant commented Jun 3, 2019

TS 3.5 broke the runtypes library which uses this as as a type parameter to a generic method of a generic interface. After simplifying the break into a 4 lines of TS declarations, it looks like the problem is caused by TS failing to correctly resolve types using the index signature of a union type where both sides of the union share the same property name but different generic property types. In 3.4, (A | B)['value'] (where both A and B are generic types with a value property) compiled OK, but in 3.5.1 and 3.6.0-dev.20190602 it triggers a "not assignable to" error when this is used as one of the type parameters.

FWIW, a slightly different syntax A['value'] | B['value'] works OK in both 3.4 and 3.5.

I see that @ahejlsberg has been working on assignment using this in #31704 and #31454, so perhaps this issue here is a case that slipped through those PRs?

TypeScript Version: typescript@3.6.0-dev.20190602

Search Terms: this interface typescript recursion recursive union index signature

Code

// this declaration fails in 3.5.1 (and 3.6.0-dev.20190602), but OK in 3.4.5 
interface R<A = unknown> {
  value: A;
  Or<B extends R>(b: B): Union<this, B>; // compiler error on `this`
}
interface Union<A extends R, B extends R> extends R<(A | B)['value']> {}

// this declaration works OK in 3.4.5, 3.5.1, and 3.6.0-dev.20190602
// The only difference is using `A['value'] | B['value']` instead of `(A | B)['value']`
interface R2<A = unknown> {
  value: A;
  Or<B extends R2>(b: B): Union2<this, B>;
}
interface Union2<A extends R2, B extends R2> extends R2<A['value'] | B['value']> {}

Expected behavior:
No compiler errors (like in 3.4.5)

Actual behavior:

Type 'this' does not satisfy the constraint 'R<unknown>'.
  Type 'R<A>' is not assignable to type 'R<unknown>'.
    Types of property 'Or' are incompatible.
      Type '<B extends R<unknown>>(B: B) => Union<this, B>' is not assignable to type '<B extends R<unknown>>(B: B) => Union<R<unknown>, B>'.
        Type 'Union<this, B>' is not assignable to type 'Union<R<unknown>, B>'.
          Types of property 'Or' are incompatible.
            Type '<B extends R<unknown>>(B: B) => Union<Union<this, B>, B>' is not assignable to type '<B extends R<unknown>>(B: B) => Union<Union<R<unknown>, B>, B>'.
              Type 'Union<Union<this, B>, any>' is not assignable to type 'Union<Union<R<unknown>, B>, any>'.
                Types of property 'Or' are incompatible.
                  Type '<B extends R<unknown>>(B: B) => Union<Union<Union<this, B>, any>, B>' is not assignable to type '<B extends R<unknown>>(B: B) => Union<Union<Union<R<unknown>, B>, any>, B>'.
                    Type 'Union<Union<Union<this, B>, any>, any>' is not assignable to type 'Union<Union<Union<R<unknown>, B>, any>, any>'.
                      Type 'Union<Union<R<unknown>, B>, any>' is not assignable to type 'Union<Union<this, B>, any>'.
                        Type 'Union<R<unknown>, B>' is not assignable to type 'Union<this, B>'.ts(2344)

Playground Link: https://www.typescriptlang.org/play/#src=%2F%2F%20this%20declaration%20fails%20in%203.5.1%20(and%203.6.0-dev.20190602)%2C%20but%20OK%20in%203.4.5%20%0D%0Ainterface%20R%3CA%20%3D%20unknown%3E%20%7B%0D%0A%20%20value%3A%20A%3B%0D%0A%20%20Or%3CB%20extends%20R%3E(b%3A%20B)%3A%20Union%3Cthis%2C%20B%3E%3B%20%2F%2F%20compiler%20error%20on%20%60this%60%0D%0A%7D%0D%0Ainterface%20Union%3CA%20extends%20R%2C%20B%20extends%20R%3E%20extends%20R%3C(A%20%7C%20B)%5B'value'%5D%3E%20%7B%7D%0D%0A%0D%0A%2F%2F%20this%20declaration%20works%20OK%20in%203.4.5%2C%203.5.1%2C%20and%203.6.0-dev.20190602%0D%0A%2F%2F%20The%20only%20difference%20is%20using%20%60A%5B'value'%5D%20%7C%20B%5B'value'%5D%60%20instead%20of%20%60(A%20%7C%20B)%5B'value'%5D%60%0D%0Ainterface%20R2%3CA%20%3D%20unknown%3E%20%7B%0D%0A%20%20value%3A%20A%3B%0D%0A%20%20Or%3CB%20extends%20R2%3E(b%3A%20B)%3A%20Union2%3Cthis%2C%20B%3E%3B%0D%0A%7D%0D%0Ainterface%20Union2%3CA%20extends%20R2%2C%20B%20extends%20R2%3E%20extends%20R2%3CA%5B'value'%5D%20%7C%20B%5B'value'%5D%3E%20%7B%7D

Related Issues: #31691 #31454 #31704 #31439 #30769

@ahejlsberg
Copy link
Member

This looks like an effect of #30769 which is a known breaking change. It used to be that (A | B)['value'] and A['value'] | B['value'] would behave the same on the target side of the assignable type relation, but (A | B)['value'] is now subject to stricter rules.

@ahejlsberg ahejlsberg added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jun 3, 2019
@justingrant
Copy link
Contributor Author

@ahejlsberg - is there a good heuristic to understand the new "stricter rules" for (A | B)['value']? I read #30769 pretty carefully but I'm having trouble translating that spec into how it applies to this case. Do the new rules effectively treat it like (A & B)['value'] because only the intersection of A and B are now valid in this context? Or is the problem that using this makes it a recursive type and the compiler can't validate the type as OK before it hits a recursion limit and gives up?

@ahejlsberg
Copy link
Member

@justingrant Yes, the reason for the difference is that (A | B)['value'] now simplifies to A['value'] & B['value'] in a target position, and that is a more restrictive type.

I did a bit of debugging to see what exactly is going on in the example.

When the base type of the Union interface is written as R<A['value'] | B['value']> we end up with a circularity as we're measuring the variance of R<A> and Union<A, B>. We therefore default both of those types to have co-variant type parameters. That in turn means that R<A> is related to R<unknown>.

When the base type is written as R<(A | B)['value']>, we'd previously simplify to the type above, but we now simplify to R<A['value'] & B['value']>. This type is restrictive enough that we don't observe a circularity, and we therefore measure R<A> and Union<A, B> to have invariant type parameters. That in turn means R<A> and Union<A, B> are only compatible with identical instantiations of themselves.

@justingrant
Copy link
Contributor Author

Makes sense. Thanks so much @ahejlsberg for the investigation and clear explanation of what's going on.

@ahejlsberg
Copy link
Member

@justingrant You're welcome. BTW, as I was looking into this I noticed the very large sets of overloaded Union and Intersection types in the runtypes library. I think you can simplify those dramatically using some of our newer features like variadic functions (#24897). Here's a sketch of what I have in mind (mostly just the types, implementations left as an exercise for the reader):

export type Runtype<T = unknown> = {
    T: T;  // Witness
    guard(value: unknown): value is T;
}

export type Record<T extends { [key: string]: Runtype }> = { [P in keyof T]: T[P]['T'] }

export declare const Record: <T extends { [key: string]: Runtype }>(def: T) => Runtype<Record<T>>;

export type Union<T extends Runtype[]> = T[number]['T'];

export declare const Union: <T extends Runtype[]>(...types: T) => Runtype<Union<T>>;

type UnionToIntersection<U> = (U extends U ? (x: U) => void : never) extends (x: infer I) => void ? I : never

export type Intersect<T extends Runtype[]> = UnionToIntersection<T[number]['T']>;

export declare const Intersect: <T extends Runtype[]>(...types: T) => Runtype<Intersect<T>>;

export declare const String: Runtype<string>;
export declare const Number: Runtype<number>;
export declare const Literal: <T extends string | number | boolean>(value: T) => Runtype<T>;

const Boolean = Union(Literal(true), Literal(false));

// { s: string, b: boolean } & { n: number } | string
const MyType = Union(Intersect(Record({ s: String, b: Boolean }), Record({ n: Number })), String);

function foo(x: unknown) {
    if (MyType.guard(x)) {
        if (typeof x === "string") {
            return x;
        }
        else {
            if (x.b) {
                return "true";
            }
            if (x.n > 10) {
                return "" + x.n;
            }
            return x.s;
        }
    }
    return "Error";
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

2 participants