7

Why does the Typescript compiler complain about the following code?

type Foo = {
  a: string
}

type Bar = {
  b: number
}

type Baz = Foo | Bar;

function f(x: Baz): number {
  if (x.a) { // property 'a' does not exist on type Bar!
    return 0;
  }

  if (x.b) { // property 'b' does not exist on type Foo!
    return 1;
  }

  return -1;
}

Link to Playground

Jared Smith
  • 19,721
  • 5
  • 45
  • 83
  • 1
    [Related meta question](https://meta.stackoverflow.com/questions/404944/is-there-a-canonical-question-for-user-defined-type-guards-in-typescript?noredirect=1#404944) – Jared Smith Feb 16 '21 at 22:42
  • 1
    thanks for the comment, I was about to search for a duplicate _"Ahhh I've already seen this one a zillion time"_ :) – Pac0 Feb 16 '21 at 22:46
  • I know it's how TS works but it's a bit strange. Sure `x.a` does not exist on `Bar` but if you pass a `Bar` object then `x.a` is just `undefined` it doesn't break the code to do a falsy check so I think this should be allowed in this particular case – apokryfos Feb 16 '21 at 23:06
  • Ok, why have you asked the question and answered it yourself :P – user2369284 Feb 16 '21 at 23:09
  • @apokryfos maybe worth it's own question? – Jared Smith Feb 16 '21 at 23:14
  • 2
    @user2369284 that's a pretty normal thing here (there's even an option when you ask a question to simultaneously answer it in the UI), see the linked meta question for more details. – Jared Smith Feb 16 '21 at 23:14
  • 1
    @apokryfos see my updated answer. – Jared Smith Feb 18 '21 at 15:01

1 Answers1

10

Why the compiler can't (or won't) allow those property accesses

Consider the following cases mentioned on this github thread linked in the comments by jcalz:

interface Vec2 {
  x: number
  y: number
}

interface Vec3 {
  x: number
  y: number
  z: number
}

const m = { x: 0, y: 0, z: "hello world" };
const n: Vec2 = m; // N.B. structurally m qualifies as Vec2!
function f(x: Vec2 | Vec3) {
  if (x.z) return x.z.toFixed(2); // This fails if z is not a number!
}
f(n); // compiler must allow this call

Playground

Here the author of the code makes an unfortunate assumption that just because a property is present and truthy that it is a certain type. But this is doubly wrong: you could have a falsey value of the correct type (zero or NaN in this case) or a truthy value of a different type. But there are subtler gotchas:

type Message =
  { kind: "close" } |
  { kind: "data", payload: object }

function handle(m: Message) {
  switch (m.kind) {
    case "close":
      console.log("closing!");
      // forgot 'break;' here
    case "data":
      updateBankAccount(m.payload);
  }
}

This is a scenario where you'd want the compiler to complain about an unintended property access, not just silently propagate undefined. Catching this sort of thing is a big part of why we use static analysis in the first place.

The Typescript compiler is already a marvelous feat of engineering layering a static type system on top of not just a dynamic language but an ultra-dynamic language. What you're looking for here is called type narrowing, where you take a value that could possibly be more than one type and then narrow it down to a specific type. The TS compiler supports (at least) five different idioms to achieve this:

  1. The instanceof operator.
  2. The typeof operator.
  3. The in operator.
  4. A user-defined type guard.
  5. A discriminated union.

Let's look at each in turn:

instanceof

This one works well for user-defined classes:

class A {
  public a: number
  constructor () {
    this.a = 4;
  }
}

class B {
  public b: number
  constructor () {
    this.b = 5;
  }
}

type AB = A | B;
function abba(x: AB): number {
  if (x instanceof A) return x.a;
  if (x instanceof B) return x.b;
  return 0;
}

Playground

typeof

This one works well for JS primitives (undefined, numbers, strings, booleans, etc).

type snumber = string | number;

function f(x: snumber): string {
  if (typeof x === 'number') {
    return x.toFixed(2); // strings don't have toFixed
  } else {
    return x.repeat(2);  // numbers don't have repeat
  }
}

Playground

in

This one works well for structurally typed objects:

type A = {
  a: number
}

type B = {
  b: string
}

type AB = A | B;

function f(x: AB): number {
  if ('a' in x) return x.a;
  if ('b' in x) return 5;
  return 0;
}

Playground

An astute reader will notice that this has the same problems as the first motivating example above, namely that the existence of a property on an object does not in any way guarantee the type. This was a pragmatic decision on the part of the TS team to allow an uncommon behavior for a simple opt-in idiom of wanting to get either the value or undefined, and much like a cast is an implicit promise that the programmer is taking responsibility for the possible outcome.

User-defined type guards

This works well for just about anything, but is more verbose than the earlier options. This one is straight from the TS Handbook:

function isFish(pet: Fish | Bird): pet is Fish { // note the 'is'
  return (pet as Fish).swim !== undefined;
}

let pet = getSmallPet();

if (isFish(pet)) {
  pet.swim();
} else {
  pet.fly();
}

Discriminated union

This works best when you have a bunch of very similar objects that differ only in the (statically knowable!) value of a single property:

type A = {
  a: string
  kind: 'is-an-a'
}

type B = {
  b: number
  kind: 'is-a-b'
}

type AB = A | B;

function f(x: AB): string {
  switch (x.kind) {
    case 'is-an-a': return x.a;
    case 'is-a-b':  return '' + x.b;
  }
}

Note that you will as I said need to make the discriminant (the kind property in this case) a statically knowable value, usually a string literal or a member of an enum. You can't use variables, because their values aren't known at compile-time.

Playground

So in summary the Typescript compiler can figure it out, you just have to use an idiom it can statically verify instead of one that it can't, and it gives you a fair number of options.

Jared Smith
  • 19,721
  • 5
  • 45
  • 83
  • 1
    Does this answer actually address the question "why does the Typescript compiler complain about the following code"? I was expecting an explanation about what happens in a union type where a property is not known to exist on all members, such as the discussion in [microsoft/TypeScript#42775](https://github.com/microsoft/TypeScript/issues/42775), mentioning structural compatibility and open/extendible object types, and how this is behavior is intentional. I'd normally add my own answer, but I see this Q/A pair is intended to be "canonical", so, uh, what do we want to do here? – jcalz Feb 17 '21 at 02:19
  • @jcalz I would suggest that you either edit my answer or add your own answer and I'll upvote it and put a link to it in mine? I'm open to ideas here, I just wanted a solid dupe target. I can also edit my own answer, but probably won't have time to do it today. – Jared Smith Feb 17 '21 at 15:52
  • I'm happy to edit this answer (but yeah I might not get to it immediately either) – jcalz Feb 17 '21 at 15:54
  • 1
    @jcalz done. Feel free to edit if anything is unclear or wrong. – Jared Smith Feb 18 '21 at 15:00
  • 1
    Nice, thanks! It's funny how `in` has technically the same issue as direct access (e.g., `if ("z" in x) return x.toFixed(2);` will *not* have a compiler error but could lead to the same runtime badness as `if (z.x)`) but they don't care. [This comment](https://github.com/microsoft/TypeScript/issues/42775#issuecomment-778419557) makes it clear that this disconnect is intentional because `in` is less likely to be done "accidentally", but it never sat right with me. – jcalz Feb 18 '21 at 15:06
  • 1
    @jcalz FWIW I agree that making people type out a `typeof` check would be preferable, but with so many tradeoffs to consider I can't blame them for making some questionable calls for the sake of ergonomics. – Jared Smith Feb 18 '21 at 15:31