microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.15k stars 12.38k forks source link

Literal inference causes unexpected errors on a values that trivially satisfy compound type intersections containing any #59473

Closed jedwards1211 closed 3 weeks ago

jedwards1211 commented 1 month ago

🔎 Search Terms

object intersection literal inference any

🕗 Version & Regression Information

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=5.5.4#code/JYWwDg9gTgLgBAJQKYEMDG8BmUIjgcilQ3wChSB6CuGAC2AGc41ckntc4UA7OCAGwAmcAG5IoDYBF4RMcALIBVAJKkYATzBI4AIQgAPAAo4wTALxwA3qThwQ-APwAuLt3VwqcZXEHBB3fHgiFH5gAC9tOkYaTW0AdxQmHnVSAF9yTwAVeiZBCDYA+AYAVzQ0NgZMYv5+dwgxKCg-SNptMBNxDRitV2FvNB44TGB9OGAYADpKah1i+DptcRwoOwqUAHMW6IHihiRhBKYWbirJbnXeuG4kfaZQdvqkECRuGDVYhXVjCFM4Cz0jCYmAAyKw2OyOFz4fabfBwAA+V2KIAARuI0uRjgx4PJ1ABhXBgP5wAAUD1MLlx31MAEo-gA+JE1cgaHqZYlUoEAbXw9nwAF0PNRkpjpNi4KMLAAeXEE8AQswAIhhSEVHkZWViTFkcHJnXcvP4cJQRDG3BY4BQMGAKP4SCmtk1PXw2Ka5zh0W4EHgiUk624KFtkQg3W0+G4yLRK0RysEmzViOK3EESGG10E+AmJIATABmbPZmkZagAdXoaFoYzuMActmUTAABvYXMkG3wVtFxnAGxHUeIEXBY-GB0mU2n9g2HEA

💻 Code

EDIT: simplified examples:

const a: [any] & [1] = [1] // Type '[number]' is not assignable to type '[1]'
const b: { ml: any } & { ml: 'edge' } = { ml: 'edge' } // Type '{ ml: string; }' is not assignable to type '{ ml: any; } & { ml: "edge"; }'

Original example:

import React from 'react'

// this comes from an old version of MUI
type BoxProps = {
  ml?: any // I didn't realize this type was any
}

// This doesn't successfully override the property type and I can fix it.
// But the error message this caused was nonsensical and needs improvement
type MyProps = BoxProps & {
  ml?: 'edge' | number
}

const MyComp = (props: MyProps) => null

type T = MyProps['ml'] // any

const x = <MyComp ml="edge" /> // Types of property 'ml' are incompatible.
  // Type 'string' is not assignable to type 'number | "edge" | undefined'.(2322)

// Which is it?  Is `ml: any` or is it `number | "edge" | undefined`?

🙁 Actual behavior

Type '{ ml: string; }' is not assignable to type '{ ml?: number | "edge" | undefined; }'.
  Types of property 'ml' are incompatible.
    Type 'string' is not assignable to type 'number | "edge" | undefined'.(2322)

🙂 Expected behavior

No error and no difference in the reported type for the ml property in JSX and MyProps['ml']

Additional information about the issue

I hope that literal inference can be improved to handle this case without an error:

const x: { ml: any } & { ml: 'edge' } = { ml: 'edge' }

Two potential solutions I proposed below are:

  1. Run a separate literal inference on each half of the required intersection type, then intersect the inferred types
  2. Fall back to unwidened type if it satisfies the required type but literal inference doesn't
RyanCavanaugh commented 1 month ago

Which one are you asking us to change?

jedwards1211 commented 1 month ago

It seems like the crux of the problem here is the way literal inference works, I wish it would infer a type of { ml: 'edge' } in this case. The compiler seems to be inferring the type of value { ml: 'edge' } against the resolved type of the intersection ({ ml: any }), resulting in the type { ml: string } instead, maybe analogous to this:

type Constraint = { ml: any } & { ml: 'edge' }
type Resolved = { [K in keyof Constraint]: Constraint[K] } // { ml: any }
const Inferred = { ml: 'edge' } satisfies Resolved // { ml: string }
const Checked: Constraint = Inferred // Type '{ ml: string; }' is not assignable to type '{ ml: "edge"; }'

What if instead, literal inference was done against both sides of the intersection, and then the results were intersected?

type Constraint = { ml: any } & { ml: 'edge' }
const InferredLeft = { ml: 'edge' } satisfies { ml: any } // { ml: string }
const InferredRight = { ml: 'edge' } satisfies { ml: 'edge' } // { ml: 'edge' }
const Inferred: typeof InferredLeft & typeof InferredRight = { ml: 'edge' }
const Checked: Constraint = Inferred // no error!

Maybe this approach would cause other problems though, I haven't thought of one yet.

Or maybe another option would be, if the initial literal inference type doesn't satisfy the constraint, the compiler tries the unwidened type (just { ml: 'edge' }) as a fallback, and uses that instead if it satisfies the constraint?

In most cases literal inference behaves in harmony with how type checking works, but in this case it feels pretty dissonant:

const x: { ml: any } & { ml: 'edge' } = { ml: 'edge' } // Type '{ ml: string; }' is not assignable to type '{ ml: any; } & { ml: "edge"; }'

It seems not ideal for a RHS that satisfies the LHS type as trivially as this to be an error.

jedwards1211 commented 1 month ago

Also I wish that literal inference wouldn't behave so differently for primitive and compound types:

const a = 1 satisfies any & 1 // type of a: 1
const b = [1] satisfies [any & 1]  // type of b: [number]
const c = { c: 1 } satisfies { c: any & 1 } // type of c: { c: number }

// would have expected either `number/[number]/{ c: number }` or `1/[1]/{ c: 1 }`
jedwards1211 commented 1 month ago

Btw I don't mean to be pedantic here; the practical downside I experienced from the current behavior is, another dev on my team was so confused by the error message they gave up and used // @ts-expect-error. I realized that I had better fix our type declaration to work correctly, but it took me awhile to understand what the problem was too.

Andarist commented 1 month ago

I recently introduced changes to https://github.com/microsoft/TypeScript/pull/52095 that fix this issue

jedwards1211 commented 1 month ago

@Andarist from reading the description that sounds different to me, since there are no indexed types or mapped types in my minimal repro here, but maybe your PR has additional effects?

And am I correct in my assumption that this issue stems from current literal inference behavior?

I think all of the following would have to work for this issue to be considered fixed (I would reopen if not):

const a: [any] & [1] = [1]
const b: any[] & 1[] = [1, 1]
const c: {a: any} & {a: 1} = {a: 1}
ahejlsberg commented 1 month ago

The issue here is our treatment of any in intersections: We say that an intersection that includes any resolves to any. For example, any & 1 resolves to any. That's consistent with our treatment of never (never & 1 resolves to never), but not with our treatment of unknown (unknown & 1 resolves to 1). Since any is a bit of both it's debatable what's "right", but realistically we can't change it since way too much code depends on the existing behavior.

That said, we can change the behavior of any in intersections of contextual element types. For example, an element of an array contextually typed by [any] & [1] really should have the contextual type 1 such that literal types are preserved. In fact, we really want any to behave as unknown in this scenario such that we preserve the most specific type possible. It's similar to how we don't do subtype reduction for contextual union types in the interest of preserving the most specific types.

I'll put up a PR that implements this.

Andarist commented 1 month ago

@ahejlsberg could you take a look at my PR? it already has this logic: https://github.com/microsoft/TypeScript/pull/52095/files#diff-d9ab6589e714c71e657f601cf30ff51dfc607fc98419bf72e04f6b0fa92cc4b8R31639-R31643

ahejlsberg commented 1 month ago

@Andarist Yes, I noticed that PR but it seemed more complex that I think it necessary. I have just put up #59528, let me know if you think it is missing anything essential.

jedwards1211 commented 1 month ago

Makes sense, thank y'all!