microsoft / TypeScript

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

Expression produces a union type that is too complex to represent #53234

Open Jym77 opened 1 year ago

Jym77 commented 1 year ago

Bug Report

🔎 Search Terms

"Expression produces a union type that is too complex to represent" (found #33130 which seems unrelated as it has been fixed)

🕗 Version & Regression Information

⏯ Playground Link

Example in Playground

💻 Code

function computed<N extends keyof Longhands>(
  property: Longhands[N][1],
  specified: Longhands[N][0]
) {
  // error happens on this line
  property.compute(specified);
}

interface Property<T> {
  compute: (value: T) => T;
}

type Wrapper<T> = [T, Property<T>];

interface Longhands {
  "font-family": Wrapper<Family>;
  "font-size": Wrapper<Size>;
  "font-stretch": Wrapper<Stretch>;
  "font-variant-caps": Wrapper<Caps>;
  "font-variant-east-asian": Wrapper<EastAsian>;
  "font-variant-ligatures": Wrapper<Ligatures>;
  "font-variant-numeric": Wrapper<Numeric>;
  "font-variant-position": Wrapper<Position>;
  "font-weight": Wrapper<Weight>;
}

class Keyword<K extends string> {
  keyword: K;
  constructor(keyword: K) {
    this.keyword = keyword;
  }
}

type Family = Keyword<"serif">;
type Size = Keyword<"length">;
type Stretch =
  | Keyword<"percentage">
  | Keyword<"ultra-condensed">
  | Keyword<"extra-condensed">
  | Keyword<"condensed">
  | Keyword<"semi-condensed">
  | Keyword<"normal">
  | Keyword<"semi-expanded">
  | Keyword<"expanded">
  | Keyword<"extra-expanded">
  | Keyword<"ultra-expanded">;
type Caps = Keyword<"normal">;
type EastAsian =
  | Keyword<"normal">
  | Keyword<"jis78">
  | Keyword<"jis83">
  | Keyword<"jis90">
  | Keyword<"jis04">
  | Keyword<"simplified">
  | Keyword<"traditional">
  | Keyword<"proportional-width">
  | Keyword<"full-width">
  | Keyword<"ruby">;
type Ligatures =
  | Keyword<"none">
  | Keyword<"normal">
  | Keyword<"common-ligatures">
  | Keyword<"no-common-ligatures">
  | Keyword<"discretionary-ligatures">
  | Keyword<"no-discretionary-ligatures">
  | Keyword<"historical-ligatures">
  | Keyword<"no-historical-ligatures">
  | Keyword<"contextual">
  | Keyword<"no-contextual">;
type Numeric =
  | Keyword<"normal">
  | Keyword<"lining-nums">
  | Keyword<"oldstyle-nums">
  | Keyword<"proportional-nums">
  | Keyword<"tabular-nums">
  | Keyword<"diagonal-fractions">
  | Keyword<"stacked-fractions">
  | Keyword<"ordinal">
  | Keyword<"slashed-zero">;
type Position = Keyword<"normal"> | Keyword<"sub"> | Keyword<"super">;
type Weight =
  | Keyword<"number">
  | Keyword<"normal">
  | Keyword<"bold">
  | Keyword<"bolder">
  | Keyword<"lighter">;

Note: interestingly, I detected the error in the much more complex real code of Alfa where it only triggers in TS 4.9.3, but not in 4.8.4 🤔 I am not sure which simplification happens in my real code to keep the union smaller…

🙁 Actual behavior

The code produces the error:

Expression produces a union type that is too complex to represent

🙂 Expected behavior

The union should be around 50 items big, far below the limit.

RyanCavanaugh commented 1 year ago

If you were close to hitting the limit before and inference slightly changed in a generally non-observable way (say, to fix a bug), you might end up hitting the limit. Encountering the error is not a per se defect; we'd need a minimal example of this being a false positive to investigate further.

Jym77 commented 1 year ago

@RyanCavanaugh Thanks for the explanation. I'm trying to trim that code and see if I can extract a workable example.

Deep down, this code should be quite far from the limit; it is modelling CSS properties and the types of their values, which shouldn't be anywhere near 100,000 🤔 OTOH, there is a map of functions whose strict type gets lost and that could cause a multiplication of cardinals of unions when we retrieve stuff, leading to the explosion 💥 Hopefully, I'll manage to either get a workable example, or improve my code to keep the numbers low enough 😅

Jym77 commented 1 year ago

@RyanCavanaugh I've managed to extract a MNWE 🎉 Updated the original message with code and link to playground. It is still 80 lines long, and the union should be around 50 items big. My attempts at simplifying it further cleared up the error (notably, the Keyword class, which ends up being just a wrapper for string, is still needed to confuse TS; and obviously removing some items from the unions goes below the limit).

Interestingly, my real code does build in TS 4.8.4, but this simplified version breaks all the way back to 4.1.5 (in 4.0.5, another error pops up preventing this one…) I'm a bit surprised by that given how much of a simplification I did on the way (notably shrinking down the union quite a lot…)

nick-kang commented 1 year ago

We've encountered this too when trying to migrate to from Typescript v4 to v5. Possible duplicate of #52459.

typescript-bot commented 1 year ago

:wave: Hi, I'm the Repro bot. I can help narrow down and track compiler bugs across releases! This comment reflects the current state of the repro in the issue body running against the nightly TypeScript.


Issue body code block by @Jym77

:x: Failed: -

Historical Information
Version Reproduction Outputs
4.6.2, 4.7.2, 4.8.2, 4.9.3, 5.0.2

:x: Failed: -

  • Expression produces a union type that is too complex to represent.

gabritto commented 1 year ago

Investigating the minimal repro posted here, it doesn't seem like this is a bug. What happens is that, when we try to compute the type of property.compute on line 6, we use the constraint type of the generic type of property. That type is Property<Family> | Property<Size> | .... Now, to get the signature of the compute property from this union type of property, we combine all of the .compute signatures from the union components. The way we combine the signatures is by intersecting the parameter types, and unioning the return types. Now, the problem in the example is that intersecting the parameter types causes us to produce a type that is too complex to represent. To see why, note that first we intersect types Family and Size, producing a simple intersection Family & Size because Family and Size are plain object types. Now, when we go and intersect that with Stretch, which is a union of 10 object types, we start getting a really big type, because to intersect type A and type B | C | D, we do a cross product and end up with A & B | A & C | A & D. So now, intersecting Family & Size and Stretch gives us a union of 10 intersections. Then further, we intersect that result with type EastAsian, which again is a union of 10 types, and we already had a union of 10 types, so now we end up with a union of 100 types. If you do the math, you can see that, by the time we try to intersect Weight to this big param type we have so far, we already have a union of 27k types, and Weight is a union of 5 types, so intersecting them would give us a union of 135k types, which is above the limit of 100k.

eltonio450 commented 1 year ago

hi @gabritto

thanks for the explanation. Following the thread as we have a similar issue

do you know why this could have appeared with typescript 5 ? could it be related to the fact that enums are now "considered" unions ?

thanks!

gabritto commented 1 year ago

hi @gabritto

thanks for the explanation. Following the thread as we have a similar issue

do you know why this could have appeared with typescript 5 ? could it be related to the fact that enums are now "considered" unions ?

thanks!

Honestly, it could be a number of different things, I can't tell without looking at an example.

ahejlsberg commented 1 year ago

Here's a simplified repro:

type Box<T> = { value: T };

type Func<T> = (x: T) => T;

declare const f1: Func<Box<10>> | Func<Box<11>>;

declare const f2:
    Func<Box<10> | Box<11> | Box<12> | Box<13> | Box<14> | Box<15> | Box<16> | Box<17> | Box<18> | Box<19>> |
    Func<Box<20> | Box<21> | Box<22> | Box<23> | Box<24> | Box<25> | Box<26> | Box<27> | Box<28> | Box<29>> |
    Func<Box<30> | Box<31> | Box<32> | Box<33> | Box<34> | Box<35> | Box<36> | Box<37> | Box<38> | Box<39>> |
    Func<Box<40> | Box<41> | Box<42> | Box<43> | Box<44> | Box<45> | Box<46> | Box<47> | Box<48> | Box<49>> |
    Func<Box<50> | Box<51> | Box<52> | Box<53> | Box<54> | Box<55> | Box<56> | Box<57> | Box<58> | Box<59>>;

declare const x: Box<10>;

f1(x);  // Error: Argument of type 'Box<10>' is not assignable to parameter of type 'never'.
f2(x);  // Error: Expression produces a union type that is too complex to represent.

As f1 illustrates, it isn't possible to supply a parameter that simultaneously is both a Box<10> and Box<11>. Therefore, f1 reduces to a function that takes a never parameter, i.e. a function that isn't callable.

The f2 declaration is just a more complicated way of writing a function that isn't callable. But, somewhat unrelated, the process of reducing the parameter type to never causes construction of a very large intermediate union type that overflows our internal limit. But one way or the other, the types are never going to work out.

ahejlsberg commented 1 year ago

So, ultimately, I'd say this is working as intended.

gabritto commented 1 year ago

I've been doing some digging, and it seems we've run into this problem before, of producing the "Expression produces a union type that is too complex to represent" for an intersection that would reduce to never arising from combining signature parameter types: https://github.com/microsoft/TypeScript/issues/42790#issuecomment-781706671. As mentioned in the linked comment, @weswigham even had a PR for it: https://github.com/microsoft/TypeScript/pull/42772.

It seems like this is something that comes up for people, given there are multiple issues mentioning this same problem and more people mentioning running into these issues. So maybe we could do something about the cases where the complex type would reduce to never.

gabritto commented 1 year ago

On the other hand, the error message is also very vague and not that helpful, it seems to me that people are surprised when their union of 50 or 200 elements becomes a union with more than 100k elements. But I don't really know what we could do about it. For instance, we could try to display the type somehow, but I'm not sure if displaying the type would help people in figuring out why we were trying to intersect a bunch of unions in the first place...

ahejlsberg commented 1 year ago

Interesting that we already have a PR in this area. We should bring it up to date and reevaluate it.

In an ideal world we'd quickly recognize intersections of object types that turn into never (because one or more of their properties intersect to never) and eliminate them early. Problem is, this requires eagerly resolving members and their types, which may lead to circularities. In @weswigham's PR we only attempt eager reduction when we're about to report an error anyways, and that may indeed be acceptable. But it sure would be nice to have a more robust scheme. I suspect it would require detecting properties with circular types and only deferring those, but I'm not sure how feasible that is.

Jym77 commented 1 year ago

@gabritto Thanks for looking into this. So, if I get this correctly, this boils down to expanding cross products like (A | B) & (C | D) into (A & C) | (A & D) | (B & C) | (B & D). I guess normalising as union of intersections makes sense most of the time, but sadly not in that one (where the intersection of unions is more compact).

I do agree that the error message was confusing, but I also fail to see how it could be made much better 🤔 In this case, this also plays on the subtlety of union of functions producing an intersection of paramters (due to contravariance).

Jym77 commented 1 year ago

Digging a bit more into it… It seems my MWNE is actually incorrect in what it tries to do.

function computed<N extends keyof Longhands>(
  property: Longhands[N][1],
  specified: Longhands[N][0]
) {
  // error happens on this line
  property.compute(specified);
}

Now, the intention is to only call the function with a property that matches the specified type. This is not the case here since I can call it with proprety: Property<Family> and specified: Size, which is incorrect (not in that example, but in my code base such a thing makes no sense).

This lack of link is (I understand) what makes the union of functions explode (the type of compute is (Family -> Family) | (Size -> Size) | … which is turned into (Family & Size & …) -> (Family | Size | …) and the explosion happens at the left of the arrow).


Trying to enforce the link between both arguments:

declare const longhands: Longhands;

function computed2<N extends keyof Longhands>(name: N) {
  const foo = longhands[name];
  // error happens on this line
  foo[1].compute(foo[0]);
}

I still get the same problem. But this time, it feels like foo[0] and foo[1] should be more closely tied (and notably, it is not possible to call that anymore with incompatible property and specified. Yet, the error still occurs. My type theory is a bit rusty 😕 I'm likely missing some obvious reason why the type still need to be exploded here.

But I guess this leads to a question… Is there a way to type that function in a way that keeps the link between property and specified and therefore does not explode the union? Or a way to type foo in the second version.


This sort of boils down to the fact that name cannot be narrowed further than "font-family" | "font-size" | … but in practice it is only going to be one of these…

function computed3(name: "font-family"): Family {
  const foo = longhands[name];
  return foo[1].compute(foo[0]);
}

works like a charm, but

function computed4<N extends "font-family" | "font-size">(name: N): Longhands[N][0] {
  const foo = longhands[name];
  // error happens on this line, foo[0] is not assignable to never
  return foo[1].compute(foo[0]);
}

breaks because foo[0] is of type Family | Size but compute ends up being of type F -> F | S -> S, reduced to (F & S) -> (F | S), reduced to never -> (F | S).

Yet, in practice, name can only be one of the two possibilities and this code does look type-safe 🤔 Am I missing something here? Is there a way to say that foo[0] is always going to be of a type compatible for foo[1]?

gabritto commented 1 year ago

Trying to enforce the link between both arguments: I still get the same problem. But this time, it feels like foo[0] and foo[1] should be more closely tied (and notably, it is not possible to call that anymore with incompatible property and specified. Yet, the error still occurs. My type theory is a bit rusty 😕 I'm likely missing some obvious reason why the type still need to be exploded here.

You're right. The fact that we "explode" the type (i.e. we use a type parameter's constraint instead of the type parameter when resolving a type), and that we don't have a concept of "name can only be one of the types in the union", are a design choice/limitation of TypeScript.

prashantbiradarvrts commented 6 months ago

Typescript: 4.3.5: lodash.get funtion giving similar issue:

get(item, [recursiveKey, 'length'])