3

I am quite curious why const { liveCount } = customer is not working at the following. tsc throws Property 'liveCount' does not exist on type 'Customer'.(2339) error. What's the difference between them and what do I need to learn more for understanding this?

TS 4.2.3 is used.

type Thing = {
    name: string
}

type Person = Thing & {
    age: number
    address?: string
}

type Cat = Thing & {
    liveCount: number
}

type Customer = Person | Cat

function deliverTo(customer: Customer) {
    // Case A: OK
    const { liveCount } = { ...customer }

    // Case B: Not OK
    // const { liveCount } = customer
}
sangheestyle
  • 1,037
  • 2
  • 16
  • 28
  • Wow, I expected the "not OK" result; you can only safely access properties that are known to exist in all members of a union, and `liveCount` may or may not appear in type `Person`. But I didn't realize that if you destructure a spead union it would magically not complain; not sure if that's a bug or intentional. – jcalz Mar 10 '21 at 03:31
  • 3
    Looks like the behavior where `const { liveCount } = { ... customer }` results in `number | undefined` was introduced in [microsoft/TypeScript#31711](https://github.com/microsoft/TypeScript/pull/31711) to support things like `{ foo } = bar || {}`. I'm guessing this is unintentional but I won't know unless I file an issue and hear back. If I get around to answering this question I'd probably suggest something like `const { liveCount } = customer as Partial;` (see [here](https://tsplay.dev/WJ9Rlm)) which at least is a known way of dealing with things. – jcalz Mar 10 '21 at 04:03
  • 3
    Okay I filed [microsoft/TypeScript#43174](https://github.com/microsoft/TypeScript/issues/43174) and I'll come back here with updates if/when they arrive. – jcalz Mar 10 '21 at 04:22
  • 1
    Well, [microsoft/TypeScript#43174](https://github.com/microsoft/TypeScript/issues/43174) is apparently considered a bug, so I will write up an answer which says not to rely on `const { liveCount } = { ...customer }` because it's not supposed to be happening that way. When I get a chance, that is – jcalz Mar 10 '21 at 17:06

1 Answers1

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is a very well written answer which deserves more upvotes! It also answers MY question about why I can't do `const file: File | Blob; const fileName = file.name || "fake name"`. I ended up with a ternary using 'in'. – tgf Apr 20 '21 at 01:00