-
Notifications
You must be signed in to change notification settings - Fork 12.9k
Description
🔎 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
💻 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 isAB
inab3
then why can it rule it out inab1
andab2
?
All of these should be typed AB, but it looks like typescript collapsed the union behind the scenes forab1
andab2
into{ [x: string]: string[] }
- Why am I not allowed to extend
ab3
withtest34
?
Here it seems like typescript collapsedab3
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
andab2.test24
as inab3.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[] };


While in ab3
it remains AB


