Home > other >  Narrow union type based on a property
Narrow union type based on a property

Time:08-30

I am running into an issue when I try to narrow a type based on a property. It's best if I show with some code what it it basically boils down to:

type User = {
  id: number;
  name: string;
}

type CreateUser = {
  name?: string;
}

const user: User | CreateUser = { name: "Foo Bar" };

if (user.id) {
  console.log(`Hello ${user.name}`)
}

So my thinking is that user is either a User or a CreateUser, and we can see the difference based on the existence of the id property. But sadly this doesn't work: Property 'id' does not exist on type 'CreateUser'. And user.name is still string | undefined instead of a simple string. Surely there is way to do this, but I am not able to find out how?

Even this doesn't work, which surprised me:

if ("id" in user) {
  console.log(`Hello ${user.name}`)
}

I get Property 'name' does not exist on type 'never'..

CodePudding user response:

When using a type annotation and initializing a value, the compiler is smart enough to narrow the value to the type that you use to initialize it. When initializing it with a type that has the name property and lacks the id property, the compiler narrows it to the CreateUser type:

const user: User | CreateUser = { name: 'Foo Bar' };

user;
//^? const user: CreateUser

To avoid this automatic narrowing behavior and preserve the union information in the same scope, you can use an assertion instead of an annotation:

const user = { name: 'Foo Bar' } as User | CreateUser;

if ('id' in user) {
          //^? const user: User | CreateUser
  console.log(`Hello ${user.name}`);
                     //^? const user: User
}

TS Playground


Response to your comment:

It works the same way when exporting a value. The important part is that every time you assign a value to the variable and want to preserve the full union type afterward in that scope, you must use an assertion to override the compiler's automatic narrowing behavior:

TS Playground

export let user: User | CreateUser;

user = { name: 'Foo Bar' } as User | CreateUser;

if ('id' in user) {
          //^? const user: User | CreateUser
  console.log(`Hello ${user.name}`);
                     //^? const user: User
}

and if this becomes tedious, just create an alias for the union:

export type AnyUser = User | CreateUser;

export let user: AnyUser;

user = { name: 'Foo Bar' } as AnyUser;

CodePudding user response:

This works:

function isRealUser(user: User | CreateUser): user is User {
  return (user as User).id !== undefined;
}

if (isRealUser(user) {
  console.log(`Hello ${user.name}`)
}

Found on https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates.

  • Related