Skip to content

Unexpected type collapse of union type at const initialization #62108

@SoulEvans07

Description

@SoulEvans07

🔎 Search Terms

"type collapse" , "type narrowing", "type narrow", "union narrow", "union narrowing", "union collapse"

🕗 Version & Regression Information

  • This is the behavior in every version I tried on Playground (even as far back as v3.3.3 and even on nightly), and I reviewed the FAQ for entries about unions.
  • Was not able to use every-ts, the git clone failed on the first every-ts switch <version>

⏯ Playground Link

https://www.typescriptlang.org/play/?skipLibCheck=true&checkJs=true&allowJs=true&target=9&resolveJsonModule=true&isolatedModules=true&noEmit=true&moduleDetection=3&verbatimModuleSyntax=true&lib=&plugins=&incremental=true&ts=5.9.0-dev.20250722#code/PQKgBApgzgNglgOwC4FoAmcoEMBGMJgACSAngA7QDGATnGatPMsAgPYoCuCHUEaKANyzUoYEMABQE4MCLQAsqzQd8ASWQRqrMgC4wSahwjTZhKAGs6AGTg4AwgAsIlc3oNGTRJMIDmEJHrQAEwADEFBnoRYMDCsAO4AUlBuhsYyRNTQrDACEEmsCIrK+Cke6YQAtkoqEAAi-s5IcAV6AGas1JRpppjZWEh8RTXJ+qmRudQ4-XAVQ-gAyiTIWAAepcaRUAZwlAGjZaZsAKoIlE4ufOpoECt8AIKUXVAjrdG8kWfO5knrUuXwOD0AG0AERoVgVEEAGjAYIhADo4ANqLh8NDYQBReahcIggC6kTYGIqSN+5SqxQgehBWIAcjckCDIhSagAlLIwDhNFqwgBCXDQ+GoTPKACsoGtYWRMrxqLkRaYyJyfIgRkCAN6whBYCoQEHUhAMkFgAC+BPKiBoEF1yxgvykpAoYDuYAAvGBNVpWHstrQED4gXjTQBuCSOgi8t0esBAyW+xA+PF6eP+wMhsPkAh3SPul0AHzAvNDEkoBS2YCVWEQqjQUYA5FgECQUOYICQ68GwF3uz2wOlSwhy5Xq2g9A2my223WS2WkPpM2ga8ntv7643m63252u-3Z-OKIvR2AUz4Z4O55QsFsa2uJ5u62Ar8eVz5t7vz2BL9ejyepAPy7gACMejZlGmrek41DAg2dZBianbvgBOBBCBvJnuWAxbIBgFRkBQLDggNZ4tuPbpIAoOToXOmFIIBQS4TggFAuGh7Eb2fayBR-5UdANEAMz0YxX5IERJGkRxEhAfC1GAQALFGQIwTCdY4LBolseRf57rgKHOjm0ZensCkqXiMIQZo0FYLBIY7rIXGPjgvGoZR+g8UEOHutp+EwFWhFoKxYlgJxe7UeE9FBExC4ib2Gl2SF-EechQJCVF0XidpUmuXJ7oKZZSnGWpAUUc5uCObpYFgAZFlWfB7GflpDFObFPG8e59m8V5Pkpd26SADLkYAYtQWhQf1+A2nOMxKjsSIwCQYAOE+jZgOO7b7gQODOFgPAEDc0rQFAzQIGArCtKtS0guuk4kCCD6XggdZzutYBbbWSCsGAiDXCsp11tmdbwmAAAKWgUNQpBLRd95gOC0BgGwc43Jgc4FN9v1SVAAAUADsIQAKy8QAlM51G8XRCXtcxXU2WAfUDUNegYqNEDIO9FSTZQ02zfNoiLctD7hmA62XltkArLtzwHUdJ383WJ43Y290CwQz36G9H03CjvJ-WAtKqwgn3HnAPjakgHCZGAcRIg4j4VsIOoNNQkvfbL5tPu0ApHYd0uo0gGPY3jhNNVsvHxW1SVXsJfkFb1-WDR09OM8zE3wOzSAzXNC2Hbzp2C5tvAi2L+3I8dTsvnLd0PUrvAvbr+te5r-06+9evq-tRv9KbBAW0gVtYDbKK6sijvS87cSu6w7vI3Xf0+1juMExJDkZUHWUxopS35Wx3WyDTsfDUD2iaGD908Q+0OiHDIuIx7Gta-vINH9Rp+sDDF8I+Wk+ZktdzTxjQTBwAnITYqOAZKoXKpVVelkTKPjVMpaqCFbL1VAbpCQ1x8ADHsjJeEV5QyByQDJVquAZIdRHP5VKgUiY8RkqTTBEUDyUy3hQvBMkQ5ELDt+Mh5CKJEKXvgleOU6x5SspvQqmkPy4BxmA90npWDeiqtAq80FjLWUQnOIhTk0ENHsjjeEBlcHBR4jjQhOAcYkN8pwxhQUPzURxjQiRdDLiR03jFAxWw8b0VMclJxzi0omOwaIbKa84FBhEZYqQEhWhcF2BLaiVwbjo1wKhGEcAfwvnxh6CQXZMgm2oIdXAQIUmsWjrTOOI1rRM3GqzZOHN07c0zuuPmn8c7Cx2jKQuh1i7D1Lp+eWFcnpVxVk3Wun8fr121jXFuhtjYd3Npba2ZBbYD00EPEZI8x4T09iM72vs56ExNFISJpxuSex4hiFYAw9YJMBLpdJ6pMn2V4fJIJG9im7z0HfQ+s1j5bCfi-b0l936bKdKM2+wNPlLUflDZ+59-lvyRkCggP0f7oz-rxQB9zsmmzyTgUM+yIlROOS5LYZyLloFpMILQcQrmoVufcuAJ10Z1gMg+RA9l0mYtyaGLsuBHmBNyuvVStUKJZP8Fi+yuKJBAA

💻 Code

/* eslint-disable @typescript-eslint/no-unused-vars */

// @esModuleInterop: true
// @skipLibCheck: true
// @target: es2022
// @allowJs: true
// @resolveJsonModule: true
// @moduleDetection: force
// @isolatedModules: true
// @verbatimModuleSyntax: true
// @strict: true
// @noUncheckedIndexedAccess: false
// @checkJs: true
// @lib: ["dom", "dom.iterable", "ES2022"]
// @noEmit: true
// @module: "ESNext"
// @moduleResolution: "Bundler"
// @jsx: "preserve"
// @plugins: [{ "name": "next" }]
// @incremental: true


type A = { root: string[] };
type B = { [x: string]: string[] };
type AB = A | B;

const plainId = 'any-key';           // const plainId: 'any-key'
const typedId: string = 'any-key';   // const typedId: string
const castId = 'any-key' as string;  // const castId: string

const ab1: AB = { other: ['a'] }; // const ab1: AB
const test11 = ab1[plainId];      // ✅
const test12 = ab1[typedId];      // ✅
const test13 = ab1[castId];       // ✅
ab1.test14 = ['a', 'b'];          // ✅

const ab2: AB = { root: ['b'], other: ['a'] };  // const ab2: AB
const test21 = ab2[plainId];     // ✅
const test22 = ab2[typedId];     // ✅
const test23 = ab2[castId];      // ✅
ab2.test24 = ['a', 'b'];         // ✅

const ab3: AB = { root: ['a'] }; // const ab3: AB
const test31 = ab3[plainId];     // ❌ Error: Element implicitly has an 'any' type because expression of type '"any-key"' can't be used to index type 'AB'. Property 'any-key' does not exist on type 'AB'.ts(7053)
const test32 = ab3[typedId];     // ❌ Error: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'AB'. No index signature with a parameter of type 'string' was found on type 'AB'.ts(7053)
const test33 = ab3[castId];      // ❌ Error: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'AB'. No index signature with a parameter of type 'string' was found on type 'AB'.ts(7053)
ab3.test34 = ['a', 'b'];         // ❌ Error: Property 'test' does not exist on type 'AB'. Property 'test' does not exist on type 'A'.ts(2339)

const ab4: AB = { root: ['a'], as: ['b'] }; // const ab4: AB
delete ab4.as;
const test41 = ab4[plainId];       // ✅
const test42 = ab4[typedId];       // ✅
const test43 = ab4[castId];        // ✅
ab4.test44 = ['a', 'b']            // ✅

function testIndex(ab: AB, id: string) {
  return ab[id]; // ❌ Error: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'AB'. No index signature with a parameter of type 'string' was found on type 'AB'.ts(7053)
}

function testExtend(ab: AB) {
  ab.test = ['a', 'b']; // ❌ Error: Property 'test' does not exist on type 'AB'. Property 'test' does not exist on type 'A'.ts(2339)
  return ab;
}

function testExtendNarrow(ab: AB) {
  if ('root' in ab) return;
  ab.test = ['a', 'b']; // ✅
  return ab;
}

🙁 Actual behavior

As the comments show on ab3 I get an error about not being able to index the union with string. This could be understandable as it cannot rule out { root: string[] } where string wouldn't be allowed.
There are 2 issues here:

  • If it cannot rule out { root: string[] } as the type is AB in ab3 then why can it rule it out in ab1 and ab2?
    All of these should be typed AB, but it looks like typescript collapsed the union behind the scenes for ab1 and ab2 into { [x: string]: string[] }
  • Why am I not allowed to extend ab3 with test34?
    Here it seems like typescript collapsed ab3 into { root: string[] } and does not allow it to flip to the other side of the union even though we only assigned values and did not change the type.

Bonus weird thing, but I can kinda see this: ab4 did not collapse to { root: string[] } after the delete and allowed the extension with test44 even though it could. I don't want it to, I just find it weird that typescript collapsed and excludes part of the union in some places (like first assignment) but wouldn't do it later.

🙂 Expected behavior

Typescript should assume/collapse the union into { [x: string]: string[] } in ab1 and ab2 cases and should throw errors in the assignment of test11, test12, test13, test21, test22, test23.

What I'm less sure about is what to do in the extension cases. It should either

  • Not allow it anywhere. So throw the same error in ab1.test14 and ab2.test24 as in ab3.test34
  • Allow it everywhere. Don't throw error in ab3.test34

As the currently the behavior seems inconsistent.
Based on the function examples at the end, it should not allow extension until you narrow the type by other means

Additional information about the issue

What I just noticed is that in VSCode, when I hover over the objects there is another inconsistency:

Here we can see in ab1 the collapse to B = { [x: string]: string[] };
Image

Image Image

While in ab3 it remains AB

Image Image Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    Not a DefectThis behavior is one of several equally-correct options

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions