英文:
why does the nullish coalescing operator removing one part of the union of records
问题
I've been scratching my head without finding an answer. I don't understand why the nullish coalescing operator removes one part of the union in the following snippet:
declare const thing:
| { id: number; version: number; label: string }
| {
label: string;
}
| undefined;
// it yields {label: string} | null instead of the expected { id: number; version: number; label: string } | { label: string; } | null
const thingWithCoallesce = thing ?? null;
I created a playground to demonstrate the case. I couldn't find anything in the issue tracker about that.
英文:
I've been scratching my head without finding an answer. I don't understand why the nullish coalescing operator removes one part of the union in the following snippet:
declare const thing:
| { id: number; version: number; label: string }
| {
label: string;
}
| undefined;
// it yields {label: string} | null instead of the expected { id: number; version: number; label: string } | { label: string; } | null
const thingWithCoallesce = thing ?? null;
I created a playground to demonstrate the case. I couldn't find anything in the issue tracker about that.
答案1
得分: 1
以下是您要翻译的内容:
这与nullish coalescing无关。编译器正在执行子类型缩减,无论何时都可以执行;请参阅microsoft/TypeScript#49952获取更多信息。
让我们给这些类型取个名字。 您的代码与以下代码基本相同:
interface Base {
label: string
}
interface Subtype extends Base {
id: number;
version: number;
}
declare const thing: Subtype | Base | undefined
// const thing: Base | Subtype | undefined
const thingWithCoallesce = thing ?? null;
// const thingWithCoallesce: Base | null
就我们的目的而言,类型Subtype
等效于{id: number; version: number; label: string}
,而类型Base
等效于{label: string}
。
类型Subtype
是Base
的子类型(因此命名为如此);每个类型Subtype
的值也是类型Base
的值:
const s: Subtype = { id: 1, version: 2, label: "abc" };
const b: Base = s; // okay
(请注意,excess property checking有时会让人们以为情况不同;请参阅https://stackoverflow.com/q/72819923/2887218获取更多信息)。
这意味着联合类型Subtype | Base
等效于Base
。您可以从关于联合的一些规则中看到这一点,例如:X extends (X | Y)
以及:如果X extends Z
且Y extends Z
,则(X | Y) extends Z
。因此,(Subtype | Base) extends Base
以及Base extends (Subtype | Base)
,因此它们是等效的。当然,TypeScript的类型系统并不完全sound,因此有时候“等效”的事物在某些情况下可能会表现出不同的行为。
好的,Subtype | Base
和Base
是等效的类型。因此,编译器可以在它“想要”的任何时候执行子类型缩减,并将Subtype | Base
实际折叠为Base
。实际上,编译器是否以及何时“想要”执行子类型缩减实际上是一个实现细节:
declare const thing: Subtype | Base | undefined
// const thing: Base | Subtype | undefined
但切换/缩小操作(如nullish coalescing)往往会减少联合:
const thingWithCoallesce = thing ?? null;
// const thingWithCoallesce: Base | null
如果这是不希望的行为,那么您可能应该更改类型,以便“每个Subtype
都是有效的Base
”不适用。如果通过{label: string}
,您指的是“具有label
但不具有id
和version
”,那么您需要明确说明:
interface OtherSubtype extends Base {
id?: never;
version?: never;
}
declare const thing: Subtype | OtherSubtype | undefined
// const thing: Subtype | OtherSubtype | undefined
const thingWithCoallesce = thing ?? null;
// const thingWithCoallesce: Subtype | OtherSubtype | null
子类型缩减不会发生,因为既不是Subtype
也不是OtherSubtype
是对方的子类型。
英文:
This isn't specific to nullish coalescing. The compiler is performing subtype reduction, which it can do whenever it "wants" to; see microsoft/TypeScript#49952 for more information.
Let's give these types names. Your code is essentially identical to:
interface Base {
label: string
}
interface Subtype extends Base {
id: number;
version: number;
}
declare const thing: Subtype | Base | undefined
// const thing: Base | Subtype | undefined
const thingWithCoallesce = thing ?? null;
// const thingWithCoallesce: Base | null
For our purposes, the type Subtype
is equivalent to {id: number; version: number; label: string}
, while the type Base
is equivalent to {label: string}
.
The type Subtype
is a subtype of Base
(hence the naming); every value of type Subtype
is also a value of type Base
:
const s: Subtype = { id: 1, version: 2, label: "abc" };
const b: Base = s; // okay
(Note that excess property checking sometimes confuses people into thinking otherwise; see https://stackoverflow.com/q/72819923/2887218 for more information).
And that means that the union type Subtype | Base
is equivalent to Base
. You can see that from following some rules about unions like: X extends (X | Y)
and: if X extends Z
and Y extends Z
then (X | Y) extends Z
. So (Subtype | Base) extends Base
and Base extends (Subtype | Base)
and thus they are equivalent. Of course TypeScript's type system isn't fully sound so sometimes "equivalent" things can behave differently in some situations.
Okay, so Subtype | Base
and Base
are equivalent types. And so the compiler may, whenever it wants, perform subtype reduction and actually collapse Subtype | Base
to Base
. It is effectively an implementation detail of the compiler whether and when it "wants" to perform subtype reduction. It happens that when you explicitly create the union it tends to stick around where you've defined it:
declare const thing: Subtype | Base | undefined
// const thing: Base | Subtype | undefined
But switching/narrowing operations like nullish coalescing tend to reduce the unions:
const thingWithCoallesce = thing ?? null;
// const thingWithCoallesce: Base | null
If this is undesirable behavior, then you should probably change your types so that "every Subtype
is a valid Base
" doesn't apply. If by {label: string}
you meant "has a label
but lacks an id
and a version
", then you will need to say so explicitly:
interface OtherSubtype extends Base {
id?: never;
version?: never;
}
declare const thing: Subtype | OtherSubtype | undefined
// const thing: Subtype | OtherSubtype | undefined
const thingWithCoallesce = thing ?? null;
// const thingWithCoallesce: Subtype | OtherSubtype | null
And the subtype reduction doesn't happen because neither Subtype
nor OtherSubtype
are subtypes of the other.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论