Type narrowing error if discriminant property is union

Viewed 56

I wrote something like the following code in TypeScript. The type ABCD is like a state object and its kind property represents the type of the state. Because some of them (kind is 'a' or 'b') have a common additional property extra, I wrote a common type of them as AB. The others don't have an extra property, so I merged them as CD as below.

type AB = {
  kind: 'a' | 'b',
  extra: string
};

type CD = {
  kind: 'c' | 'd'
};

type ABCD = AB | CD;

function printExtra(x: ABCD) {
  if (x.kind === 'c' || x.kind === 'd') return;
  console.log(x.extra);
}

The function printExtra prints extra property if kind is neither 'c' nor 'd'. I was convinced the compilation will pass because that if statement asserts x is not CD and because AB is the only candidate for the type, obviously x is AB. But the TypeScript compiler failed to compile with the following message.

Property 'extra' does not exist on type 'ABCD'. Property 'extra' does not exist on type 'CD'.

Why does the above code failed to compile? I have also tried the following code and confirmed it compiles successfully, but if possible I don't want to use it because it is somewhat redundant in my project.

type A = { kind: 'a', extra: string };
type B = { kind: 'b', extra: string };
type C = { kind: 'c' };
type D = { kind: 'd' };

type ABCD = A | B | C | D;
3 Answers

You could refactor your code to check the other way around

 function printExtra(x: ABCD) {
    if (x.kind === 'a' || x.kind === 'b') {
        console.log(x.extra); // no error
    }
  }

Taking your logic a step further:

type A = { kind: 'a', extra: string };
type B = { kind: 'b', extra: string };
type C = { kind: 'c' };
type D = { kind: 'd' };

type AB = A | B;
type CD = C | D;

type ABCD = AB | CD;

So then this would also work:

type AB = ({ kind: 'a' } | { kind: 'b' }) & { extra: string };
type CD = { kind: 'c' } | { kind: 'd' };

type ABCD = AB | CD;

Let's make this a bit less verbose:

type Kind<T extends string> = { [K in T]: { kind: K } }[T];

type AB = Kind<'a' | 'b'> & { extra: string };
type CD = Kind<'c' | 'd'>;

type ABCD = AB | CD;

TS Playground

There is alternative way to do it. You can use assertions.

type AB = {
    kind: 'a' | 'b',
    extra: string
};

type CD = {
    kind: 'c' | 'd'
};

type ABCD = AB | CD;

function assertIsAB(x: ABCD): asserts x is AB {
    if (x.kind !== 'a' && x.kind !== 'b') {
        throw new Error("Not a string!");
    }
}


function printExtra(x: ABCD) {
    assertIsAB(x);
    const result = x; // AB
    console.log(x.extra); // ok
}

Drawback: you should handle error

Personally, I prefer second approach, with typeguards. It is much safer:

type AB = {
    kind: 'a' | 'b',
    extra: string
};

type CD = {
    kind: 'c' | 'd'
};

type ABCD = AB | CD;

const isAB = (x: ABCD): x is AB => x.kind === 'a' || x.kind === 'b'

function printExtra(x: ABCD) {
    if (isAB(x)) {
        const result = x.extra; // AB
    }
}
Related