TypeScript
TypeScript copied to clipboard
Inference is broken on intersection type utilizing polymorphic `this`
Bug Report
🔎 Search Terms
inference, intersection, polymorphic this, deferred index access
🕗 Version & Regression Information
- This changed between versions 5.1.0-dev.20230313 and 5.1.0-dev.20230315
⏯ Playground Link
Playground link with relevant code
💻 Code
export interface TypeLambda {
readonly A: unknown
}
export interface TypeClass<F extends TypeLambda> {
readonly _F: F
}
export type Apply<F extends TypeLambda, A> = F extends { readonly type: unknown }
? (F & { readonly A: A })['type']
: { readonly F: F, readonly A: A }
export interface T<A> {
value: A
}
export interface TTypeLambda extends TypeLambda {
readonly type: T<this["A"]>
}
export declare const map: <F extends TypeLambda>(F: TypeClass<F>) => <A, B>(a: Apply<F, A>, f: (a: A) => B) => Apply<F, B>
declare const typeClass: TypeClass<TTypeLambda>
declare const a: T<number>
map(typeClass)
// TS 5.0.4: <A, B>(a: T<A>, f: (a: A) => B) => T<B>
// TS 5.1.0-dev.20230422: <A, B>(a: (TTypeLambda & { readonly A: A; })["type"], f: (a: A) => B) => (TTypeLambda & { readonly A: B; })["type"]
map(typeClass)(a, (_) => _)
// ^ TS 5.0.4 infers number
// TS 5.1.0-dev.20230422 infers unknown
🙁 Actual behavior
Each version including and after 5.1.0-dev.20230315 defers the index access in the Apply type and doesn't allow inference to continue.
🙂 Expected behavior
Inference should continue to work in this case.
After a bit of research, I think that #53098 is the PR that introduced this change.
This currently breaks the encoding used in @effect/* for HKTs and probably a few other libraries that rely on the same pattern (hotscript, storybook, come to mind)
Same here, zodios is using this pattern to enable policy based interfaces.
Seems like those packages should be added to the RWC test suite maybe, or snippets of them added to the main compiler tests
Feel free to send a PR: https://github.com/microsoft/TypeScript-error-deltas
See userTests.
Of course, any PR would include a test case for this.
@ahejlsberg if you find another way to better support HKT, it would also be fine from my perspective. The need is to be able to pass an utility type to another utility type on type level and still have type constraint available.
I can confirm that the change in behavior is caused by #53098. We definitely want the deferred resolution implemented by that PR. However, it means that types like (TTypeLambda & { readonly A: A; })["type"] aren't resolved until a non-generic type is substituted for A through instantiation and, unfortunately, it also means that no inferences are made for A in the map(typeClass) invocation.
I think we can implement a targeted fix where, when inferring to types like (TTypeLambda & { readonly A: A; })["type"], we force the resolution that previously happened and then infer to the result of that. This means the type of map(typeClass) will still change as in the example above, but inference will infer number in the final example.
I can confirm that the change in behavior is caused by #53098. We definitely want the deferred resolution implemented by that PR. However, it means that types like
(TTypeLambda & { readonly A: A; })["type"]aren't resolved until a non-generic type is substituted forAthrough instantiation and, unfortunately, it also means that no inferences are made forAin themap(typeClass)invocation.I think we can implement a targeted fix where, when inferring to types like
(TTypeLambda & { readonly A: A; })["type"], we force the resolution that previously happened and then infer to the result of that. This means the type ofmap(typeClass)will still change as in the example above, but inference will infernumberin the final example.
This is very problematic we will have to make a backward incompatible change with serious degradation to developer experience, I am honestly in doubt about what feature of TS to trust, this behaviour was even confirmed as intended explicitly by a TS core member in prior issues. This way we don't have any way of safely develop libraries without risk that in 2 releases the TS team decides to change semantic behaviour.
I don't think we need to be this dramatic. I see this as an opportunnity to clarify TS team stance on HKT. Community started evolving libraries to take advantage of some HKT features. As a TS team you have a playground feedback on how community took advantage of it. Based on this feedback, you Can now either :
- officially support a new HKT syntax to address current use cases
- officially announce HKT to not be supported in either way going forward
Clarification is key for us to know if we can leverage some ts features for our use cases.
Again, thank you @ahejlsberg and all ts team for your wonderfull work
I don't think we need to be this dramatic. I see this as an opportunnity to clarify TS team stance on HKT. Community started evolving libraries to take advantage of some HKT features. As a TS team you have a playground feedback on how community took advantage of it. Based on this feedback, you Can now either :
- officially support a new HKT syntax to address current use cases
- officially announce HKT to not be supported in either way going forward
Clarification is key for us to know if we can leverage some ts features for our use cases.
Again, thank you @ahejlsberg and all ts team for your wonderfull work
I mean that was valid behaviour since TS3, I appreciate the work a lot and TS is a fantastic language but unless we can have some sort of guarantee things won't break (unless by mistake) I don't see how to go forward, there are many edge behaviours that are relied upon from the community and this is totally normal for a language without a spec but it only works if we preserve the "strange behaviour" priorly introduced. I can list a number of features that may break with implications to many libraries like for example behaviour of overload selection, many libraries rely on the current ordering and selection algorithm, what if with the ongoing effort to improve inference we end up breaking? I'd always assumed if we ended up there the decision would be not to accept the changes (maybe at the price of perfection) to maximise usability. If instead the policy is "yeah but the behaviour should have really been Y" then I am afraid I can't be sure of most of the TS behaviour, and I am saying this while generally appreciating the efforts and the language, but as as library developer I need to know the boundaries
I understand that esthetically T<A> looks nicer than (TTypeLambda & { readonly A: A; })["type"], though the latter really is a more precise representation of what was requested in Apply. Are there other issues that are caused by this change and not fixed by #54112?
We use:
export const f = map(typeClass)
and let it derive the type of f, with this change the typing of f will be impractical to use and understand from the reader's perspective, the problem seems to be related to potential reduction to never that this change aims to preserve, would there be a way of avoiding it in a class of cases where never is known to be impossible? I guess that would require being able to express "TypeLambda is an object without the field A" ? or maybe a way to force it to reduce the type?
In a general sense from a user perspective it isn't necessarily the case that something more precise is always better
Actually I may have found a way:
export interface TypeClass<F> {
readonly _F: F
}
export declare namespace TypeLambda {
type _A<T> = "A" extends keyof T ? T["A"] : never
}
interface TypeLambda { type: unknown }
export type Apply<F, A> = F extends TypeLambda
? (F & {A: A})["type"]
: { readonly F: F, readonly A: A }
export interface T<A> {
value: A
}
export declare const map: <F>(F: TypeClass<F>) => <A, B>(a: Apply<F, A>, f: (a: A) => B) => Apply<F, B>
interface TTypeLambda {
type: T<TypeLambda._A<this>>
}
declare const typeClass: TypeClass<TTypeLambda>
declare const a: T<number>
// const x: <A, B>(a: T<A>, f: (a: A) => B) => T<B>
const x = map(typeClass)
map(typeClass)(a, (_) => _)
Not sure if this if considered a bug with the new behaviour or this can be used (usage)
It turns out there's a simple way to fix this issue and preserve our old behavior. See latest commits in #54112.