Skip to content

tsconfig disable: is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint #36821

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

Closed
5 tasks done
SephReed opened this issue Feb 15, 2020 · 22 comments
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript

Comments

@SephReed
Copy link

SephReed commented Feb 15, 2020

Search Terms

Suggestion

Add an --ignoreSubtypeConstraint -- but better named -- to make these errors non-blocking

Use Cases

In my experience, almost any time this error comes up, it's a bit of a time sink. I appreciate its correctness, but the edge case that it protects against has not yet been helpful for me. By being able to let this type of error slide by, I could strike a better balance between effort and correctness during my work hours.

Examples

This is a great example of something I'd like to configure typescript to overlook:

const func1 = <A extends string>(a: A = 'foo') => `hello!` // Error!

const func2 = <A extends string>(a: A) => {
    //stuff
    a = `foo`  // Error!
    //stuff
}

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@SephReed
Copy link
Author

SephReed commented Feb 15, 2020

As an alternative, it could also be nice to be able to define a generic as non-narrowing.

For example, using the made up keyword openly:

const testFn = <A openly extends string>(a: A = 'foo') => `hello!`;

testFn<"qux" | "baz">("qux"); // Error: generic type "qux" | "baz" is not an open extension of type "string"

const quxBaz: "qux" | "baz" = "qux";
testFn(quxBaz); // Should probably not error;

@jcalz
Copy link
Contributor

jcalz commented Feb 16, 2020

Do these examples resemble actual use cases? If so, I'd say that you should either not be using generics or you should just use a type assertion to allow unsafe narrowings:

const notGeneric = (a: string = "foo") => "hello";
const asserted = <A extends string>(a = "foo" as A) => "hello";

Maybe you have a more fleshed-out motivating example for why you want this feature? Something that I'd look at and think "hmm, yeah, that clearly should be a generic function and the type assertion is incredibly annoying to use". Probably some object type instead of string?


As for the "openly extends" suggestion, I'm confused. What sort of types should be assignable to A openly extends string? Is it only string? If so, I'm still thinking "don't use generics for this". Or, does A openly extends string mean string extends A? If so, this has been suggested before in #14520 as A super string. Or does it mean something else?

@tadhgmister
Copy link

I really don't think adding a compiler flag to ignore this is the correct solution here. I'm pretty sure your use cases are where you want a generic function where if the argument is not passed the argument and generic both default to some value, like this:

function wrap<V extends string = "foo">(val: V = "foo" as V) {
    return [val] as [V];
}
const a = wrap("hello"); // typeof a <==> ["hello"]
const b = wrap();        // typeof b <==> ["foo"]

// this should probably not be allowed....
const c = wrap<"hello">(); // typeof c <==> ["hello"] but thats not correct at runtime!

The issue with doing = "foo" as V is that it means that passing an explicit generic but not passing the argument gives a different type than we will actually have at runtime, to correct this we would need to define overloads so passing the generic and not passing an argument would be not allowed:

function wrap2(): ["foo"];
function wrap2<V extends string>(val: V): [V]
function wrap2(val: string = "foo"){
    return [val];
}
// now this isn't allowed yay!
wrap2<"hello">();

declare const value: "hi" | undefined;
// but neither is this since there isn't an overload that accepts undefined :(
wrap2(value); // would we want this to be ["hi" | "foo"] ?

So our overload signature didn't capture a case where a variable that might be undefined is passed in, in that case our generic V will need to extend string | undefined but in our output we need to replace any undefined case with "foo" so the fully accurate working example is this:

type ReplaceUndefined<T,R> = T extends undefined ? R : T;
function wrap3(): ["foo"];
function wrap3<V extends string | undefined>(val: V): [ReplaceUndefined<V,"foo">]
function wrap3(val: string = "foo"){
    return [val];
}
const d = wrap3(value); // typeof d <==> ["hi" | "foo"]

This gets absurdly complicated to perfectly represent such a simple case. I am on board with having a possibly less complicated way of handling default values in generic functions but I haven't been able to think of any decent ways.

I certainly don't think just ignoring the error is a good idea.

@SephReed
Copy link
Author

SephReed commented Feb 18, 2020

Okay, here's the case I'm working with:

export type TestProxy<TARGET> = (
  TARGET extends Array<any> ? TestArray<TARGET> : 
  TARGET
)

type TestArray<TYPE extends Array<any>> = {
  [key in (keyof TYPE & number)]: TYPE[key];
} & {
  length, push, concat,
  join, reverse, shift, slice,
  sort, splice, unshift, indexOf,
  lastIndexOf, every, some, forEach,
  filter, reduce, reduceRight, find,
  findIndex, fill, copyWithin, [Symbol.iterator],
  entries, keys, values, [Symbol.unscopables],
  includes, flatMap, flat,

  // pop,
  pop(): TestProxy<TYPE[number]>;
  map<OUT>(cb: (it: TYPE[number], index: number, array?: TestArray<TYPE>) => OUT): TestProxy<Array<OUT>>;
}
function proxyMe<TYPE>(target: TYPE): TestProxy<TYPE> {
  return target as any; // imagine proxy
}

const hat: string = proxyMe("hat"); // okay
const obj: { arr: number[]} = { arr: []};
obj.arr = proxyMe([1, 2, 3]);  // 'TestArray<U>' is assignable to the constraint of type 'U', but 'U'

If you switch out the two definitions of pop above -- one of which is already commented out -- you'll find the code passes type checking. The implication of this is that Array<number> and TestProxy<Array<number>> are equivalent. Further, because TestProxy<TYPE> of any other type will just be TYPE this should mean that -- in all cases -- const thing: TYPE = null as TestProxy<TYPE> should be allowed.

So why does making pop return TestProxy<TYPE[number]> make a difference?

My point with this issue is: I don't know and I'd really rather not be putting the time in right now to figure it out. Perhaps, in the future I'd really like to geek out over exactly what's going on here. . But right now, I've got more than enough unit tests to be happy with my code. I want to skip this lesson and get back to my job.

@jcalz
Copy link
Contributor

jcalz commented Feb 18, 2020

Can you edit that code so that when it's dropped in the Playground it demonstrates what you're talking about and only that? Right now this is a lot of red squigglies and the error you're talking about doesn't match, and if I switch to the other pop it doesn't make any error go away. And I still am not sure why type assertions don't meet your need here.

@SephReed
Copy link
Author

SephReed commented Feb 18, 2020

Playground Link

Updated Code:

export type TestProxy<TARGET> = (
  TARGET extends Array<any> ? TestArray<TARGET> : 
  TARGET
)

type TestArray<TYPE extends Array<any>> = {
  [key in (keyof TYPE & number)]: TYPE[key];
} & {
  length: any, push: any, concat: any,
  join: any, reverse: any, shift: any, slice: any,
  sort: any, splice: any, unshift: any, indexOf: any,
  lastIndexOf: any, every: any, some: any, forEach: any,
  filter: any, reduce: any, reduceRight: any, find: any,
  findIndex: any, fill: any, copyWithin: any, [Symbol.iterator]: any,
  entries: any, keys: any, values: any, [Symbol.unscopables]: any,
  includes: any, flatMap: any, flat: any,

  // pop: any,
  pop(): TestProxy<TYPE[number]>;
  map<OUT>(cb: (it: TYPE[number], index: number, array: TestArray<TYPE>) => OUT): TestProxy<Array<OUT>>;
}
function proxyMe<TYPE>(target: TYPE): TestProxy<TYPE> {
  return target as any; // imagine proxy
}

const hat: string = proxyMe("hat"); // okay
const obj: { arr: number[] } = { arr: [] };
const testArr = proxyMe([1, 2, 3]);
obj.arr = testArr;  // 'TestArray<U>' is assignable to the constraint of type 'U', but 'U'

@jcalz
Copy link
Contributor

jcalz commented Feb 18, 2020

Okay so the issue here is the compiler's relative inability to manipulate conditional types that depend on an unspecified generic type parameter. (Someone should be able to find related issues in GitHub for this; assignability of generic conditional types)

And the suggestion is to provide a compiler option whereby an assignment to such a generic conditional type is allowed as long as some specification of the generic would make the assignment valid? Like an "optimistic generic narrowing" option? I could see that, I guess.

I still feel that type assertions are the right approach in these cases; I'd rather see the compiler get better at dealing with unspecified generics than letting people turn off a whole class of error that is often correctly pointing out a real problem.


For the particular code above, if TestProxy<T> is always supposed to be assignable to T, then I'd be inclined to fix this by intersecting with T as in type TestProxy<T> = T & (T extends Array<any> ? TestArray<T> : T) or the like. Or use a type assertion proxyMe([1,2,3]) as number[].

@SephReed
Copy link
Author

SephReed commented Feb 18, 2020

I have a very serious question here, pointed at the core beliefs of the TypeScript team: What is the problem with letting users -- of their own free will -- opt out of features that do not benefit them?


In terms of your solutions:

  1. Union: I tried this first, but ended up with nightmare type assertion errors. I'm trying to keep my types as flat and simple as humanly possible, because if I don't, I end up with insane errors

  2. Type assertion: Any typescript tool which constantly requires users to re-assign or re-assert types in order to be useful is not going to be well received. This would quite literally render my tool unusable to most users.

  3. Optimistic narrowing: While this does seem like a good approach, I'm not very optimistic about it happening. I think it would be really useful for smoothing out how complicated types can become, but... it's too much of an ask and I accept that.


What I think makes most sense is allowing users to opt-out of not just this, but literally anything they choose.

In a perfect world, the compiler would always be right... I want to see that world too. But nothing is perfect, your compiler included. Sometimes your rules need exceptions, and rather than brick-walling users until new rule is made it's a lot more logical to let users opt-out and hopefully fix the problem after some discussion.

Let's be honest here: what's almost certainly going to happen is that you guys are just going to tell me -- indirectly of course -- that I don't matter enough to change things so I can just deal with it. There will be this whole song and dance about progress, but I've seen better ideas from more popular people get nowhere.

And yet it would be so easy to make things better:

If you guys create an opt-out flow now, it will make it so every single flaw in the compiler is a non-blocker from now until the end of time.

@jcalz
Copy link
Contributor

jcalz commented Feb 18, 2020

In case it's not obvious (maybe it is, and if so, sorry), I'm not part of the TypeScript team; I'm just a very loud fan. 🏲 🕫 GO TEAM! 🕫 🏲


I'd be more inclined to support such opt-in/opt-out features if they could be contained to libraries and not infect a whole project. As a developer I wouldn't want to use a tool if it forced me to write a million type assertions, but I also wouldn't want to use it if it forced me to turn off a class of compiler errors that are otherwise useful. 🤷‍♂️

@SephReed
Copy link
Author

SephReed commented Feb 18, 2020

@jcalz, you've been great. You've helped me a ton on SO as well, and I really appreciate it.


I'd be more inclined to support such opt-in/opt-out features if they could be contained to libraries and not infect a whole project

I'd expect it to be along the same lines as noImplicitAny. Some part of tsconfig


Where I'm at -- if you can imagine -- is at the tail end of spending a year of free-time creating a recursive state manager which can be used exactly like a standard object in every testable way except for this one single type error that is provably an error in the compiler.

Essentially, I have a recursive redux using proxies, with an army of unit tests to make sure it works (because proxies).

For now, the best I've got is adding a pretend() function that I can call anytime this error comes up:

type TestArray<TYPE extends Array<any>> = {
  [key in (keyof TYPE & number)]: TYPE[key];
} & {
  pretend(): TYPE
  // code ommited
}

If you can think of anything better, let me know.

@tadhgmister
Copy link

tadhgmister commented Feb 19, 2020

Ok I now understand what you are trying to do, sorry my first comment was totally unrelated... I think I can shed some light on what is going wrong with your definitions though.

First thing is that the message 'TestProxy<U>' is assignable to the constraint of type 'U' in this case the constraint on U is {} so all that message is saying is that both TestProxy<U> and U are both objects. So by just bypassing this error you are basically removing any usefulness of generics:

function demo<T>() {
    /*
Type 'string[]' is not assignable to type 'T'.
  'string[]' is assignable to the constraint of type 'T', 
  but 'T' could be instantiated with a different subtype of constraint '{}'.ts(2322)
*/
    let x: T = [
        "anything goes if you are only validating the implicit constraint of {}",
        "btw, EVERYTHING except undefined and null are assignable to {}",
    ];
}

So don't focus on the "assignable to constraint" part, focus on the "not assignable to T" part, that will give you a better indication of what typescript is trying to tell you.

type error that is provably an error in the compiler.

Further, because TestProxy<TYPE> of any other type will just be TYPE this should mean that -- in all cases -- const thing: TYPE = null as TestProxy<TYPE> should be allowed.

Nope, sorry but your definition actually doesn't support this:

type TYPE = Array<string> & { extraField: string };
const thing: TYPE = null as any as TestProxy<TYPE>
/*
Type 'TestArray<TYPE>' is not assignable to type 'TYPE'.
  Property 'extraField' is missing in type 'TestArray<TYPE>' but required in type '{ extraField: string; }'.ts(2322)
*/

So when typescript tells you Type 'TestProxy<U>' is not assignable to type 'U' It means that your definition of TestProxy isn't setup to unconditionally be assignable to it's type argument. If you want that to be the case then TestArray<TYPE> shouldn't just copy all the array fields, it needs be TYPE (an intersection of it and extra things)

type TestArray<TYPE extends Array<any>> = TYPE & {
    pop(): TestProxy<TYPE[number]>;
    map<OUT>(cb: (it: TYPE[number], index: number, array: TestArray<TYPE>) => OUT): TestProxy<Array<OUT>>;
  }

This works, and reduces your type definitions which also reduces error messages when it is invalid in something.

@SephReed
Copy link
Author

Okay, I finally now see your point about "What if a user used a special extended array."

Hopefully an Exact<> comes out some day, because I have zero intention of supporting custom arrays.


Your solution does not show the right types:

Playground Link

Screen Shot 2020-02-19 at 12 01 31 PM

You can see in the image that the type of pop() is not TestProxy<number[]>. This is explicitly what I'm trying to avoid.


Is there a way I can enforce that TestProxy<TYPE> will also have { extraField: string } without having overloads? I know I can use Pick<> and Omit<> but those completely thrash error messaging.

@SephReed
Copy link
Author

SephReed commented Feb 19, 2020

This works:

export type TestProxy<TARGET> = (
  TARGET extends Array<any> ? TestArray<TARGET> : 
  TARGET
)

type TestArray<TYPE extends Array<any>> = {
  [key in (keyof TYPE & number)]: TYPE[key];
}
& Omit<TYPE, keyof Array<any>> // now covering any weird extensions of Array
& {
  length: any, push: any, concat: any,
  join: any, reverse: any, shift: any, slice: any,
  sort: any, splice: any, unshift: any, indexOf: any,
  lastIndexOf: any, every: any, some: any, forEach: any,
  filter: any, reduce: any, reduceRight: any, find: any,
  findIndex: any, fill: any, copyWithin: any, [Symbol.iterator]: any,
  entries: any, keys: any, values: any, [Symbol.unscopables]: any,
  includes: any, flatMap: any, flat: any,

  pop: any,
  // pop(): TestProxy<TYPE[number]>;
  map<OUT>(cb: (it: TYPE[number], index: number, array: TestArray<TYPE>) => OUT): TestProxy<Array<OUT>>;
}

type TYPE = Array<string> & { extraField: string };
const thing: TYPE = null as any as TestProxy<TYPE>;   // no errors here

So this means that whether TYPE is an Array or anything else, it will always be equivalent. Once again, why does making pop() return TestProxy<TYPE[number]> change anything if TYPE and TestProxy<TYPE> are equivalent?

Playground Link

@RyanCavanaugh
Copy link
Member

If you guys create an opt-out flow now, it will make it so every single flaw in the compiler is a non-blocker from now until the end of time.

We did; it's // @ts-ignore

There are many cases where this error is 100.0% correct and stops real unsoundnesses from occurring. There are also cases where this error is due to a limitation in TS's ability to reason about your code. A flag is the wrong solution to "Sometimes this error is correct; sometime it's too conservative" because you should really be thinking about these on a case-by-case basis; it's not the case that a project should unilaterally decide that it wants generics to just be extremely unsound everywhere.

@RyanCavanaugh RyanCavanaugh added Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript labels Feb 19, 2020
@SephReed
Copy link
Author

There are many cases where this error is 100.0%

a broken clock.

it's not the case that a project should unilaterally decide

This is an opinion. To say something can never be is unprovable. I'm literally living the experience that negates this.

There are also cases where this error is due to a limitation in TS's ability to reason about your code

So let me tell it to stop trying.

@SephReed
Copy link
Author

At this point it's obvious that "No, fuck me, I don't matter and my problems aren't real," but this is a legit bug, so I'm going to open up a non-feature request issue about it.

@RyanCavanaugh
Copy link
Member

We've declined probably hundreds of commandline flags and none of them for the reason you've assumed. Please don't take things personally.

@SephReed
Copy link
Author

SephReed commented Feb 19, 2020

I'm okay with "No."

But what you're saying is "No, your problem isn't real." You don't have to add that part last part.

You could say:

  • "No, It's not a priority right now"
  • or "No, I just don't want to."

... and I'd take it. Fine. It's your project. But this:

it's not the case that a project should unilaterally decide

Is provably false and patronizing; as if I don't know what I'm doing. You're allowed to say no. But talking down to me for trying to get around a currently unnecessary, non-beneficial brick-wall is -- rightfully -- offensive.

@RyanCavanaugh
Copy link
Member

But what you're saying is "No, your problem isn't real."

Please don't put words in my mouth and then tell me I shouldn't have said them.

I'm making benign normative statements here; our experience is that this particular error is frequently encountered in situations where a) the code is objectively wrong and b) the user doesn't realize it yet. I've tweeted about this many times, e.g. because it's so frequently encountered. A flag to disable this error entirely would be akin to a flag that would disable our error message when writing someObj[someOtherObj] - counterproductive because, even though there are a few small cases where this is OK, the great majority of the time the error is identifying a correct bug.

Literally, if I had to pick one error where people say "You shouldn't have given me this error" and the response is most likely to be "No, your code is actually wrong", this would be the one.

We have thousands of different errors and approximately five commandline flags for disabling specific generally-correct errors (all of which we regret adding). Our general experience is that it's not a productive solution and that ts-ignore on a case-by-case basis is a much better approach.

@SephReed
Copy link
Author

SephReed commented Feb 19, 2020

Please don't put words in my mouth and then tell me I shouldn't have said them.

Sorry. I meant to say: "It feels like." You are right though, that was inflammatory.

Literally, if I had to pick one error where people say "You shouldn't have given me this error" and the response is most likely to be "No, your code is actually wrong", this would be the one.

How is dealing with this more fun than watching users ignore your warnings and shoot themselves in the foot? How do you expect users to learn the value if they can't experience its absence? And what if you're wrong?

@SephReed
Copy link
Author

I work in the field of leading large scale art pieces, and the amount of ridiculous ideas artists have is over the top. So I tell them what to worry about, and how not to get too burnt and let them go for it. It's something I learned in leadership training, and it's a super effective way to let things you didn't think would work happen while simultaneously protecting your people from huge flaws.

If you block a person from experimentation, you'll never get the best out of them. There's some really dope shit I'm trying to do with TypeScript, and it's probably not going to happen because of this error.

@tadhgmister
Copy link

tadhgmister commented Feb 19, 2020

You can see in the image that the type of pop() is not TestProxy<number[]>. This is explicitly what I'm trying to avoid.

You can probably work around that by reversing the order, so ExtraFields & TYPE instead of TYPE & ExtraFields, it at least works in this playground. This just reverses the order of method overloads so you access your methods first.

export type TestProxy<TARGET> = TARGET extends Array<any> ? TestArray<TARGET> : TARGET;
interface TestArrayOverrides<T> {
    pop(): TestProxy<T> | undefined;
    map<OUT>(cb: (it: T, index: number, array: this) => OUT): TestArray<OUT[]>;
    /** to ensure that pop() returns correct type */
    rock_out(): void;
}
type TestArray<Orig extends Array<any>> = TestArrayOverrides<Orig[number]> & Orig;


function proxyMe<TYPE>(target: TYPE): TestProxy<TYPE> {
    return target as any; // imagine proxy
}

const obj: { arr: number[][] } = { arr: [] };
const testArr = proxyMe([[1], [2], [3]]);
const subarr = testArr.pop()!
subarr.rock_out(); // extra field is visible 😄 

const normalArr: number[][] = testArr; // also assignable 😄 
const normalSub: number[] = subarr;

If that doesn't work for you I'm not sure how to get it so TestArray<T> is unconditionally assignable to T but also omits Ts signature for pop and map methods.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants