Home > Enterprise >  typescript: "narrowing" a union type to one of its component
typescript: "narrowing" a union type to one of its component

Time:01-14

Suppose I have this union type:

type Foo = {tag: 'A', name: string} | {tag: 'B', value: number}

I am trying to write a function that gets a tag value ('A' | 'B') and then does some business-specific lookup in a list of Foos and returns a matching element. For the purpose of this question we can think of this very trivial function:

function findFirst(foos: Foo[], tag: 'A' | 'B') {
  for (const foo of foos) {
    if (foo.tag === tag) { 
      return foo 
    }
  }
  return undefined
}

Suppose I now call this function passing 'A' as the second argument. It is clear that it either returns undefined or a {tag: 'A', name: string} object. It just cannot return a {tag: 'B', value: number} in this situation.

Still, the TSC does not seem to be able to infer this. When writing an expression such as:

findFirst([{tag: 'A', name: 'X'}], 'A')?.name

The TSC errors with Property 'name' does not exist on type 'Foo'..

I am guessing that I need to help the compiler infer the right by expliticly defining the return type of findFirst() but I am not sure how.

(playground)

CodePudding user response:

You need to add a type parameter to the function to capture the actual type of tag being passed in. You then need to filter the Foo union using the Extract predefined conditional type:

function findFirst<T extends Foo['tag']>(foos: Foo[], tag: T) {
  for (const foo of foos) {
    if (foo.tag === tag) { 
      return foo as Extract<Foo, { tag: T }>
    }
  }
  return undefined
}

Playground Link

CodePudding user response:

You can use function overloads to declare the result without having to use cast.

type FooA = {tag: 'A', name: string};
type FooB = {tag: 'B', value: number};

type Foo = FooA | FooB 

function findFirst(foos: Foo[], tag: 'A'): FooA | undefined;
function findFirst(foos: Foo[], tag: 'B'): FooB | undefined;
function findFirst(foos: Foo[], tag: 'A' | 'B') {
  for (const foo of foos) {
    if (foo.tag === tag) { 
      return foo 
    }
  }
  return undefined
}


findFirst([{tag: 'A', name: 'X'}], 'A')?.name
findFirst([{tag: 'A', name: 'X'}], 'B')?.value

// TS Error as expected
findFirst([{tag: 'A', name: 'X'}], 'A')?.value


Playground link

  • Related