Typescript narrowing to identical types narrows to never

160 Views Asked by At

I've run into this typescript error

Property 'id' does not exist on type 'never'.

I've produced a simplified example here (code sandbox here)

interface Get {
  id: number;
}
interface Delete {
  id: number;
}

const isGet = (narrowMe: Get | Delete, narrowBy: string): narrowMe is Get =>
  narrowBy === "GET";
const isDel = (narrowMe: Get | Delete, narrowBy: string): narrowMe is Delete =>
  narrowBy === "DELETE";

const foo = (narrowMe: Get | Delete, narrowBy: string) => {
  if (isGet(narrowMe, narrowBy)) {
    return narrowMe.id;
  }
  if (isDel(narrowMe, narrowBy)) {
    return narrowMe.id; // error happens here
  }
};

The real code is more complex, but essentially, the problem I think is that the two interfaces above are identical. If I add a fake field to Get interface, to make them different, then the error goes away. I'm struggling to find documentation on what exactly is happening here and how I can fix it or workaround it. Adding a fake field is hacky and I don't really want to combine Get and Delete into a shared type unless there is no other way.

2

There are 2 best solutions below

3
On

Get and Delete interfaces are the same thing. TypeScript has something called duck typing, so after you check if narrowMe comforms to Get interface, TS knows it also comforms to Delete interface as well, so your second if is never actually possible. Things like that are usually solved with a discriminant:

interface Get {
  type: 'get';
  id: number;
}
interface Delete {
  type: 'delete';
  id: number;
}

I agree that it does look hacky, but that's just a nature of TypeScript type system. If two interfaces are the same, they describe the same set of possible objects. Their name just does not matter and you have to discriminate them somehow else.

interface Get {
  type: 'get';
  id: number;
}
interface Delete {
  type: 'delete';
  id: number;
}

const isGet = (narrowMe: Get | Delete): narrowMe is Get =>
  narrowMe.type === "get";
const isDel = (narrowMe: Get | Delete): narrowMe is Delete =>
  narrowMe.type === "delete";

const foo = (narrowMe: Get | Delete, narrowBy: string) => {
  if (isGet(narrowMe)) {
    return narrowMe.id;
  } else if (isDel(narrowMe)) {
    return narrowMe.id; // no error, narrowMe is of type Delete
  }
};
0
On

The problem is that using type guards this way is not how they were intended, and thus will behave in unexpected manners.

The easiest solution is to base your logic on not on the type guards, but rather on the narrowBy variable:

interface Get {
    id: number;
}
interface Delete {
    id: number;
}

const isGet = (narrowMe: Get | Delete, narrowBy: string): narrowMe is Get =>
    narrowBy === "GET";
const isDel = (narrowMe: Get | Delete, narrowBy: string): narrowMe is Delete =>
    narrowBy === "DELETE";

function foo(narrowMe: Get | Delete, narrowBy: string): number {
    switch (narrowBy) {
        case "GET":
        case "DELETE":
            return narrowMe.id;
        default:
            throw new Error(`Invalid parameter narrowBy, expected "GET" or "DELETE" but got ${narrowBy}`);
    }

};

Playground