1

Generic Case

In a function

const myFunction = <T, K extends keyof T>(obj: T, key: K): SpecificType => {
  const x: SpecificType = obj[key] // ❌ Type 'T[string]' is not assignable to type SpecificType
}

Is it possible to define myFunction(obj, key) in such a way I can grant that obj[key] extends SpecificType?

Example

Given the following function:

type KeysMatching<T, V> = {
  [K in keyof T]-?: T[K] extends V ? K : never
}[keyof T]

export const groupArrayBy = <
  T extends Record<string, unknown>,
  K extends KeysMatching<T, string | number | symbol>
>(
  array: T[],
  key: K
) => {
  return array.reduce((acc, item) => {
    const group = item[key]
    if (!acc[group]) {
      acc[group] = []
    }
    acc[group].push(item)
    return acc
  }, {} as Record<T[K], T[]>) // ❌ Type 'T[string]' is not assignable to type 'string | number | symbol'
}

Typescript complains about defining {} as Record<T[K], T[]> because T[K] is unknown (as given by T extends Record<string, unknown>). But, in reality, it is not, T[K] should only be string | number | symbol, because K extends KeysMatching<T, string | number | symbol>

(This auxiliary type was extracted from this question)

In fact, if I call

const t = [
  {
    valid: 'string',
    alsoValid: 1,
    group: 2,
    invalid: [],
    outroInvalid: {}
  }
]

groupArrayBy(t, 'valid') // ✅ everything ok
groupArrayBy(t, 'invalid') // ❌ not ok, as expected

And, even so, typescript cannot tell that, in any valid call to groupArrayBy([obj], key), obj[key] will always be string | number | symbol, which should be compatible with Record<T[K], T[]>

ftoyoshima
  • 363
  • 5
  • 10
  • 2
    Without [ms/TS#48992](https://github.com/microsoft/TypeScript/issues/48992) the compiler can't understand the implication of `KeysMatching` for generics; you'll probably want to add a reverse constraint as shown [in this playground link](https://tsplay.dev/m3B4kN). Does that fully address the question? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Apr 19 '23 at 23:24
  • This solves the problem! I didn't know TS accepts `T` to be defined as `Type` when K is `OtherType`. Sounds like a dependency loop. Good thing it works! – ftoyoshima Apr 20 '23 at 13:06

1 Answers1

2

TypeScript is unable to do much analysis on conditional types that depend on generic type parameters, like KeysMatching<T, PropertyKey> inside the body of groupArrayBy. It's more or less opaque to the compiler; it understands that K extends KeysMatching<T, V> will be a valid key type for T, but it loses the thread that T[K] will be assignable to V. It would be nice if there were a "native" KeysMatching<T, V> implementation that preserved the intent this way, as requested in microsoft/TypeScript#48992, but for now it's not part of the language, so we'll need to work around it.

The type checker is better at understanding generic indexed accesses into mapped types, so the workaround I'd suggest is to reverse the constraint and make T depend on K like Record<K, PropertyKey> (using the Record<K, V> utility type which is a mapped type of the form {[P in K]: V}):

export const groupArrayBy = <
    T extends Record<K, PropertyKey>,
    K extends keyof T 
>(
    array: T[],
    key: K
) => {
    return array.reduce((acc, item) => {
        const group = item[key]
        if (!acc[group]) {
            acc[group] = []
        }
        acc[group].push(item)
        return acc
    }, {} as Record<T[K], T[]>) // okay
}

interface Foo {
    a: string,
    b: number,
    c: boolean
}
declare const foos: Foo[]
groupArrayBy(foos, "a"); // okay
groupArrayBy(foos, "b"); // okay
groupArrayBy(foos, "c"); // error
// --------> ~~~~
// Argument of type 'Foo[]' is not assignable to 
// parameter of type 'Record<"c", PropertyKey>[]'.
groupArrayBy(foos, "d"); // error
// --------> ~~~~
// Argument of type 'Foo[]' is not assignable to 
// parameter of type 'Record<keyof Foo, PropertyKey>[]'.

That works without even using KeysMatching. The only downside there might be that the errors are on the array when you might expect to see them on the key. If you care, then you can keep the original KeysMatching constraint on K as well:

export const groupArrayBy = <
    T extends Record<K, PropertyKey>,
    K extends KeysMatching<T, PropertyKey>
>(
    array: T[],
    key: K
) => {
    return array.reduce((acc, item) => {
        const group = item[key]
        if (!acc[group]) {
            acc[group] = []
        }
        acc[group].push(item)
        return acc
    }, {} as Record<T[K], T[]>) // okay
}

groupArrayBy(foos, "c"); // error
// --------------> ~~~~
// Argument of type '"c"' is not assignable to 
// parameter of type 'KeysMatching<Foo, PropertyKey>'.(2345)
groupArrayBy(foos, "d"); // error
// --------------> ~~~~
// Argument of type '"d"' is not assignable to 
// parameter of type 'KeysMatching<Foo, PropertyKey>'.(2345)

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360