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

Interfaces and types that use conditional types and inheritance no longer assignable #32608

Closed
mtreder opened this issue Jul 29, 2019 · 11 comments
Assignees
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed

Comments

@mtreder
Copy link

mtreder commented Jul 29, 2019

TypeScript Version: 3.6.0-dev.20190727

Search Terms: conditional, generic, inheritance, variance

Code
Prior to the --strictFunctionTypes compiler flag in the strict suite, this previously worked fine.

type GetPropertyNamesOfType<T, RestrictToType> = { [PropertyName in Extract<keyof T, string>]: [T[PropertyName]] extends [RestrictToType] ? PropertyName : never }[Extract<keyof T, string>];
type GetAllPropertiesOfType<T, RestrictToType> = Pick<T, GetPropertyNamesOfType<Required<T>, RestrictToType>>;

export interface IRequestA<T extends object>{
    getValue(name: keyof GetAllPropertiesOfType<T, string>): string;
    getObject<ObjectType extends object = never>(name: keyof GetAllPropertiesOfType<T, ObjectType>): IRequestA<ObjectType>;
}

interface Base {
    BaseString: string;
    BaseNumber: number;
    BaseObject: Base;
}

interface Derived extends Base {
    DerivedString: string;
    DerivedNumber: number;
    DerivedObject: Base;
}

function main(a: IRequestA<Derived>): void {
    const a1: IRequestA<Base> = a;
    a1.getValue("BaseString");
    a1.getObject<Base>("BaseObject");
}

As the result of a suggestion in Issue 28671 the interface was switch to a type. This worked until TypeScript version 3.5 where the assignment again started to fail when inheritance was involved with T.

type GetPropertyNamesOfType<T, RestrictToType> = { [PropertyName in Extract<keyof T, string>]: [T[PropertyName]] extends [RestrictToType] ? PropertyName : never }[Extract<keyof T, string>];
type GetAllPropertiesOfType<T, RestrictToType> = Pick<T, GetPropertyNamesOfType<Required<T>, RestrictToType>>;

export type IRequestB<T extends object> = {
    getValue(name: keyof GetAllPropertiesOfType<T, string>): string;
    getObject<ObjectType extends object = never>(name: keyof GetAllPropertiesOfType<T, ObjectType>): IRequestB<ObjectType>;
}

interface Base {
    BaseString: string;
    BaseNumber: number;
    BaseObject: Base;
}

interface Derived extends Base {
    DerivedString: string;
    DerivedNumber: number;
    DerivedObject: Base;
}

function main(b: IRequestB<Derived>): void {
    const b1: IRequestB<Base> = b;
    b1.getValue("BaseString");
    b1.getObject<Base>("BaseObject");
}

With some changes I got it to work by switching around the allowed property name list:

type GetPropertyNamesOfType<T, RestrictToType> = { [PropertyName in Extract<keyof T, string>]: [T[PropertyName]] extends [RestrictToType] ? PropertyName : never }[Extract<keyof T, string>];
type GetAllPropertiesOfType<T, RestrictToType> = Pick<T, GetPropertyNamesOfType<Required<T>, RestrictToType>>;

export type IRequestC<T extends object> = {
    getValue<K extends keyof GetAllPropertiesOfType<T, string>>(name: K): string;
    getObject<ObjectType extends object = never>(name: keyof GetAllPropertiesOfType<T, ObjectType>): IRequestC<ObjectType>;
}

interface Base {
    BaseString: string;
    BaseNumber: number;
    BaseObject: Base;
}

interface Derived extends Base {
    DerivedString: string;
    DerivedNumber: number;
    DerivedObject: Base;
}

function main(c: IRequestC<Derived>): void {
    const c1: IRequestC<Base> = c;
    c1.getValue("BaseString");
    c1.getObject<Base>("BaseObject");
}

Expected behavior:
I would expect b to be assignable to b1 in the example above to be able to work with the subset of properties that are specific to the base type.

Actual behavior:
The assignment doesn't work or I need to jump through hoops to make it work.

I don't know if I'm just trying to limp by staying one step ahead of type checker improvements that will again cause me to make changes or possibly disallow me from accomplishing what I'm looking for.

I'm also not sure about the inconsistency in the need to update the getValue method versus getObject.

Playground Link:
Demo

Related Issues:
Issue 28671
Issue 24190

@RyanCavanaugh
Copy link
Member

Is this the simplest possible repro?

@mtreder
Copy link
Author

mtreder commented Aug 1, 2019

I can remove the extra layer of creating a type to pull property names from via keyof, but still see the error.

Reproduction

@mtreder
Copy link
Author

mtreder commented Aug 1, 2019

This issue sounded related, but I didn't see the error go away when I was using the under dev typescript version:

Issue 31804

@RyanCavanaugh RyanCavanaugh added the Needs Investigation This issue needs a team member to investigate its status. label Aug 1, 2019
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 3.7.0 milestone Aug 1, 2019
@RyanCavanaugh
Copy link
Member

@ahejlsberg this looks like a possible incorrect variance measurement. IRequestB should be covariant in T but it's being treated as invariant because of the T[PropertyName] usage in the conditional type, but it seems like the check expression in a conditional isn't really a valid variance measurement site? Not sure

@jack-williams
Copy link
Collaborator

jack-williams commented Aug 6, 2019

I think the issue here is with the simultaneous use of keyof T and T[PropertyName], rather than the conditional type. Repro without conditional types that I think is roughly faithful:

type GetPropertyNamesOfType<T, K extends number> = {
    [PropertyName in keyof T & string]: [T[PropertyName], PropertyName][K]
}[keyof T & string];

type GetAllPropertiesOfType<T, K extends number> = Pick<T, GetPropertyNamesOfType<Required<T>, K> & keyof T>;

export type IRequestB<T extends object, K extends number> = {
    getValue(name: keyof GetAllPropertiesOfType<T, K>): string;
    getObject<ObjectType extends object = never>(name: keyof GetAllPropertiesOfType<T, K>): IRequestB<ObjectType, K>;
}

interface Base {
    BaseString: string;
}

interface Derived extends Base {
    DerivedString: string;
}

function main(b: IRequestB<Derived, 1>): void {
    const b1: IRequestB<Base, 1> = b;
}

Related to #32311 and #32674.

@mtreder
Copy link
Author

mtreder commented Aug 7, 2019

I think the issue here is with the simultaneous use of keyof T and T[PropertyName], rather than the conditional type. Repro without conditional types that I think is roughly faithful:
type GetPropertyNamesOfType<T, K extends number> = {
[PropertyName in keyof T & string]: [T[PropertyName], PropertyName][K]
}[keyof T & string];

type GetAllPropertiesOfType<T, K extends number> = Pick<T, GetPropertyNamesOfType<Required, K> & keyof T>;

export type IRequestB<T extends object, K extends number> = {
getValue(name: keyof GetAllPropertiesOfType<T, K>): string;
getObject(name: keyof GetAllPropertiesOfType<T, K>): IRequestB<ObjectType, K>;
}

interface Base {
BaseString: string;
}

interface Derived extends Base {
DerivedString: string;
}

function main(b: IRequestB<Derived, 1>): void {
const b1: IRequestB<Base, 1> = b;
}
Related to #32311 and #32674.

The intent in my example was to pull the property names for a property that matches a given data type. The getValue method should only allow specifying a "name" of a property on T that is of type string (i.e. BaseNumber and BaseObject should be excluded). It is also filtering out property names that aren't of type string, but my example doesn't have any property names that aren't strings. The quoted example above looks to be a different way to express Extract<keyof T, string>.

@mtreder
Copy link
Author

mtreder commented Aug 7, 2019

It is also a need for this to work with a list of objects of a given type. My current workarounds don't seem to allow me to not produce an error.

type GetPropertyNamesOfType<T, RestrictToType> = { [PropertyName in Extract<keyof T, string>]: [T[PropertyName]] extends [RestrictToType] ? PropertyName : never }[Extract<keyof T, string>];
type GetAllPropertiesOfType<T, RestrictToType> = Pick<T, GetPropertyNamesOfType<Required<T>, RestrictToType>>;

export interface IRequestListOfObjects<T extends object>{
	length: number;
	getObject(index: number): IRequestC<T> | undefined;
}

export type IRequestC<T extends object> = {
    getValue<K extends keyof GetAllPropertiesOfType<T, string>>(name: K): string;
    getObject<ObjectType extends object = never>(name: keyof GetAllPropertiesOfType<T, ObjectType>): IRequestC<ObjectType>;
	getListOfObjects<ObjectType extends object = never>(name: keyof GetAllPropertiesOfType<T, ObjectType[]>): IRequestListOfObjects<T>;
}

interface Base {
    BaseString: string;
    BaseNumber: number;
    BaseObject: Base;
	BaseObjectList: Base[];
}

interface Derived extends Base {
    DerivedString: string;
    DerivedNumber: number;
    DerivedObject: Base;
	DerivedObjectList: Base[];
}

function main(c: IRequestC<Derived>): void {
    const c1: IRequestC<Base> = c;
    c1.getValue("BaseString");
    c1.getObject<Base>("BaseObject");
	c1.getListOfObjects<Base>("BaseObjectList");
}

Example

@RyanCavanaugh
Copy link
Member

Here's a cough simplified demo that the variance is measured incorrectly relative to a manual instantiation. IRequestC<Base> and IRequestBase should have the same respective relations to IRequestC<Derived> and IRequestDerived.

interface Base {
    BaseString: string;
    BaseObject: Base;
}

interface Derived extends Base {
    DerivedString: string;
    DerivedObject: Base;
}

type GetPropertyNamesOfType<T, RestrictToType> = {
    [PropertyName in keyof T]: T[PropertyName] extends RestrictToType ? PropertyName : never
}[keyof T];

export type IRequestC<T> = {
    getObject: (name: GetPropertyNamesOfType<T, object>) => void;
}

export type IRequestDerived = {
    getObject: (name: GetPropertyNamesOfType<Derived, object>) => void;
}
export type IRequestBase = {
    getObject: (name: GetPropertyNamesOfType<Base, object>) => void;
}

function concreteContravariant(base: IRequestBase) {
    let derived: IRequestDerived = base;
}

function concreteCovariant(derived: IRequestDerived) {
    let base: IRequestBase = derived;
}

function genericContravariant(base: IRequestC<Base>): void {
    const derived: IRequestC<Derived> = base;
}

function genericCovariant(derived: IRequestC<Derived>): void {
    const base: IRequestC<Base> = derived;
}

@jack-williams
Copy link
Collaborator

As check types are related bivariantly I would expect that variance measuring going wrong for the check type would make the measured types more assignable, rather than less. (False-negative)

type Alias<T> = [T] extends [number] ? true : false;

interface Foo<T> {
    x: Alias<T>;
}

const a: Foo<number> = { x: true };
const b: Foo<number | boolean> = a; // no error
const shouldBeFalse: false = b.x;

const c: Foo<number | boolean> = { x: false };
const d: Foo<number> = c; // no error
const shouldBeTrue: true = d.x;

Conversely, the extends types is invariant so that usually makes measured types less assignable, rather than more. (False-positive).

type Alias2<T> = [number] extends [T] ? true : false;

interface Foo2<T> {
    x: Alias2<T>;
}

const a: Foo2<number> = { x: true };
const b: Foo2<number | boolean> = a; // error
const shouldbeTrue: true = b.x;

I think trying to get any meaningful measurements from a conditional type is hard. When the true and false types are unrelated then measurements are just wrong because the conditional type cannot be an order preserving function of its outer type parameters.

My concern is that removing measurements for conditional types would be a breaking change for many people that used any as the instantiation for a measured conditional type.

@mtreder
Copy link
Author

mtreder commented Feb 13, 2020

I just wanted to check in on this to see if there was an idea on when this would be addressed. I am trying to determine whether I should look at alternatives for my API.

@ahejlsberg
Copy link
Member

This looks to be the same issue as #32674. When a type parameter appears in both a co- and contra-variant position within a type, as in T[keyof T], we measure that type parameter as invariant. This is a conservative measurement and it may indeed not always be the case, as illustrated by my comment here. The only real fix here is to mark such occurrences as unmeasurable and force structural evaluation--but we know there are significant performance penalties associated with that.

@ahejlsberg ahejlsberg added Design Limitation Constraints of the existing architecture prevent this from being fixed and removed Needs Investigation This issue needs a team member to investigate its status. Rescheduled This issue was previously scheduled to an earlier milestone labels Oct 26, 2020
@ahejlsberg ahejlsberg removed this from the TypeScript 4.1.0 milestone Oct 26, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed
Projects
None yet
Development

No branches or pull requests

4 participants