You may only access properties which exist on all members of a union.
The following behavior is as expected:
declare const customer: Customer;
const livecount = customer.liveCount; // error!
// Property 'liveCount' does not exist on type 'Customer'
// Property 'liveCount' does not exist on type 'Person'.
TypeScript will only allow you to access a property key on a value of a union type if that property key is known to exist in every member of the union. See "Working with union types" in the TS Handbook. In the above, you cannot access customer.liveCount
because no property key named "liveCount"
is known to exist on a value of type Person
.
So for all the code in your example, we should expect an error that liveCount
does not exist on Customer
. The fact that your "Case A", const { liveCount } = { ...customer }
happens not to result in an error, is apparently a bug in TypeScript. See microsoft/TypeScript#43174, which I filed about this very question. At least as of today (Mar 10 2021) that bug is slated to be fixed for TypeScript 4.3.1, so I would not be surprised if in the near-ish future, you would see the same error in both "Case A" and "Case B".
If you are thinking "but wait, isn't customer.liveCount
of type number | undefined
?", it's probably because your thought process has proceeded like this:
customer
is either a Cat
or a Person
. ✔
- If
customer
is a Cat
, then customer.liveCount
will be a number
. ✔
- If
customer
is a Person
, then customer
will not have a liveCount
property at all. ❌
- If
customer
does not have a liveCount
property, then customer.liveCount
will be undefined
. ✔
- So
customer.liveCount
will either be number
or undefined
. ❌
- So
customer.liveCount
has type number | undefined
. ❌
The statements marked with check marks (✔) are correct, but the ones marked with cross marks (❌) are incorrect for TypeScript. Let's focus on where this first goes astray:
- If
customer
is a Person
, then customer
will not have a liveCount
property at all. ❌
This is not true. Object types in TypeScript are open or extendible. You can add properties to an object type without violating the original type. This is a good thing because it enables interface and class extensions to form type hierarchies. If bar
is of type Bar
, and Bar extends Foo
, then bar
is also of type Foo
. But it has some annoying implications also, like we see here.
A value of type Person
is known to have: a property named name
whose type is string
; a property named age
whose type is number
; and optionally a property named address
which, if present, has type string | undefined
. But a value of type Person
is not known to lack any other properties. Specifically, it is not known to lack a property named liveCount
. And there is no constraint on what kind of property might exist at that key. It might be number
, or string
, or Date
, or anything. The only safe thing to say is that it is something like unknown
or any
. So we have to amend our logic to something like this:
customer
is either a Cat
or a Person
. ✔
- If
customer
is a Cat
, then customer.liveCount
will be a number
. ✔
- If
customer
is a Person
, then customer
may have a liveCount
property whose type could be anything at all. ✔
- So then
customer.liveCount
will be any
. ✔
- So
customer.liveCount
will either be number
or any
. ✔
- So
customer.liveCount
has type any
, and it's probably a mistake to access the liveCount
property on customer
in the first place. ✔
Case in point:
const undeadCount = {
name: "Dracula",
age: 1000,
liveCount: false
}
const weirdPerson: Person = undeadCount;
deliverTo(undeadCount);
Here, undeadCount
is a valid Person
because it has a name
and an age
of the right type. It has a liveCount
property of type boolean
, but this does not disqualify it from being a Person
, as demonstrated by the fact that I can assign it to a variable weirdPerson
of type Person
without error. And so I can also call deliverTo(undeadCount)
. If the implementation of deliverTo()
expects customer.liveCount
to be a number
if it's not undefined
, some very weird things could happen. You could even have runtime errors (e.g., customer.liveCount?.toFixed(2)
).
Note that things would be different if you had a way of saying "a Customer
is either exactly a Cat
or exactly a Person
, where in neither case could there be extra properties I don't know about". But TypeScript does not currently have support for so-called "exact types", and there is a longstanding feature request to implement them at microsoft/TypeScript#12936. So, in the absence of a reasonable way to say Exact<Cat> | Exact<Person>
, what can be done?
How to proceed instead
There are different workarounds. One thing you can do is just assert that your customer
has an optional liveCount
property which is either number
or undefined
. Sure, it's technically possible that someone might pass in an undeadCount
, but you really don't expect for that to happen and you don't want to waste your time worrying about such an unlikely event. One such assertion is this:
const { liveCount } = customer as Partial<Cat>; // okay
Here we are saying that customer
will either have the properties from Cat
or they will be missing and therefore undefined
. This includes liveCount
.
A more elaborate version of this workaround is to say that customer
is an ExclusiveCustomer
defined like this:
type ExclusiveCustomer = {
name: string;
age: number;
address?: string | undefined;
liveCount?: undefined;
} | {
name: string;
liveCount: number;
age?: undefined;
address?: undefined;
};
You're explicitly saying that if customer
is a Person
then it lacks any Cat
-specific properties, and vice versa. You can even get the compiler to compute ExclusiveCustomer
from the Customer
type via a type function like ExclusiveUnion
, from this other SO question:
const { liveCount } = customer as ExclusiveUnion<Customer>;
Another workaround is to use narrowing to get customer
to be considered either a Person
or a Cat
by checking things about it. The easiest way to do this is, on the face of it, completely at odds with the whole first part of this answer about open types:
const liveCount = "liveCount" in customer ? customer.liveCount : undefined;
// const liveCount: number | undefined
Whaaaat? Yes, you can use the in
operator as a type guard which narrows customer
from Customer
to Cat
(if "liveCount" in customer
is true) or Person
(if it is false). This type guard was implemented in microsoft/TypeScript#15256 and the relevant comment in there implies that people need some way to do this and this is sufficiently indicative of intent that we will let them do it, even if it is technically unsafe:
The reality is that most unions are already correctly disjointed and don't alias enough to manifest the problem. Someone writing an in
test is not going to write a "better" check; all that really happens in practice is that people add type assertions or move the code to an equally-unsound user-defined type predicate. On net I don't think this is any worse than the status quo (and is better because it induces fewer user-defined type predicates, which are error-prone if written using in
due to a lack of typo-checking).
That leads to the next way to do this: write your own type guard via a function that returns a type predicate.
function isCat(x: any): x is Cat {
return x && typeof x.liveCount === "number" && typeof x.name === "string";
}
const liveCount = isCat(customer) ? customer.liveCount : undefined;
// const liveCount: number | undefined
By writing isCat()
that returns x is Cat
, we are telling the compiler it is allowed to use isCat()
to narrow something to either Cat
(if true) or something that is not a Cat
(if false). With you call isCat(customer)
that means either Cat
or Person
. I happened to implement isCat()
in the most type-safe way possible, but I took on that responsibility and the compiler abdicated it. One could just as easily write
function isCat(x: any): x is Cat {
return Math.random() < 0.5 //
}
and the compiler would be none the wiser. So there you go.
RECAP
- You may only safely access properties which exist on all members of a union.
- The behavior you saw where it works is a bug. Don't count on it.
- Weird bad things can happen if you make the assumption that types in TypeScript are exact.
- But they probably won't happen so you can use type assertions to make the compiler stop complaining.
- Or you could use type narrowing to make the compiler stop complaining, with varying degrees of safety.
Playground link to code