Search code examples
typescripttypescript-typingstypescript-generics

Generics and arrays: why do I end up with `Generic<T[]>` instead of `Generic<T>[]`?


I have a simple function that can accept a single object of type T or an array of objects of type T[]. It will then do its thing and return a result matching the type passed in (i.e. if an array is passed an array of results are returned, and if a single item is passed, a single result is returned).

The transpiled JS functions exactly as expected, but the type system keeps insisting on nesting the array inside the generic instead and I'm not sure why or how to ensure it resolves to what I want.

Example Function

type OneOrMany<T> = T | T[]
type Item = Record<string, any>
type CapitalizedProps<T extends Item> = {
  [K in keyof T as Capitalize<K & string>]: T[K]
}

function toCapitalizedProps<T extends Item>(item: T): CapitalizedProps<T>
function toCapitalizedProps<T extends Item>(items: T[]): CapitalizedProps<T>[]
function toCapitalizedProps<T extends Item>(
  itemOrItems: OneOrMany<T>,
): OneOrMany<CapitalizedProps<T>> {
  if (Array.isArray(itemOrItems)) {
    return itemOrItems.map((item) =>
      toCapitalizedProps(item),
    ) as CapitalizedProps<T>[]
  }

  const result = { ...itemOrItems }

  for (const key in result) {
    result[(key[0].toUpperCase() + key.slice(1)) as keyof T] = result[key]
    delete result[key]
  }

  return result as unknown as CapitalizedProps<T>
}

Solution

  • Function overloads use the first match.

    function toCapitalizedProps<T extends Item>(item: T): CapitalizedProps<T>
    function toCapitalizedProps<T extends Item>(items: T[]): CapitalizedProps<T>[]
    

    And it turns out that an array matches the Item constraint of Record<string, any>, because an array has string properties that all have values assignable to any.

    const test: Record<string, any> = [] as string[] // fine
    

    So when you pass an array, the first overload is used, which infer T as an array type.


    If you swap the order, then the overload that needs to be an array will be checked first and used if it is an array. If not, it will go to the next one.

    function toCapitalizedProps<T extends Item>(items: T[]): CapitalizedProps<T>[]
    function toCapitalizedProps<T extends Item>(item: T): CapitalizedProps<T>
    

    See playground