Search code examples
typescripttypescript-genericsunion-types

How to narrow TypeScript union type with generics


I have a generic function to update state (use-case is dynamically handling updates to tables in React), and have used generics to ensure calling the function is type-safe, but I don't understand why TypeScript won't compile.

In the function itself it seems like Typescript isn't using all the available information to narrow down the type, and instead thinks I'm still working with the full union. It clearly knows enough about the arguments to tell whether the function is being called correctly, so why does it fail when type-checking the actual implementation?

Fails at row[field] = value.

Minimal example:

type First = { a: string; b: string; c: string }
type Second = { b: string; c: string; d: string }
type Third = { c: string; d: string; e: string }

type State = {
  first: First[]
  second: Second[]
  third: Third[]
}

const update = <
  S extends State,
  TK extends keyof State,
  T extends S[TK],
  R extends T[number],
  F extends keyof R
>(
  state: State,
  tagName: TK,
  rowIndex: number,
  field: F,
  value: R[F]
) => {
  // fine
  const tag = state[tagName]

  // fine
  const row = tag[rowIndex]
  // keyof typeof row is now 'c'

  // TYPE ERROR
  row[field] = value
}

const state: State = {
  first: [{ a: "", b: "", c: "" }],
  second: [{ b: "", c: "", d: "" }],
  third: [{ c: "", d: "", e: "" }],
}

// this succeeds as expected
update(state, "first", 0, "a", "new")

// and this fails as expected
// @ts-expect-error
update(state, "second", 0, "a", "new")

Playground


Solution

  • Try this:

    const update = <
      TK extends keyof State,
      F extends keyof State[TK][number]
    >(
      state: State,
      tagName: TK,
      rowIndex: number,
      field: F,
      value: State[TK][number][F]
    ) => {
      // fine
      const tag = state[tagName]
    
      // fine
      // The type annotation here is required
      // (otherwise the type is inferred as First | Second | Third)
      const row: State[TK][number] = tag[rowIndex]
    
      // now also fine
      row[field] = value
    }