Search code examples
typescriptgenericsrecordintersectiontemplate-literals

Intersection of Record with generic key extending template literal doesn't infer result of index access


In principle (T & Record<K, U>)[K] should evaluate to U but it doesn't seem to work in the case where K is generic and extends a template literal.

function foo3<
    K extends `a${string}`,
>(k: K) {
    const a = {} as {b: 1} & Record<K, string>

    const r = a[k]
    r.toLowerCase() // Property 'toLowerCase' does not exist on type '({ a: 1; } & Record<K, string>)[K]'.(2339)
}

It works where K extends a "simple" type like string or a string literal.

Playground

What would be a way to make this work and remain typesafe?


Solution

  • In cases where TypeScript fails to understand that (T & Record<K, U>)[K] is equivalent to (or a subtype of) U, I usually widen T & Record<K, U> to Record<K, U> via reassignment, since TypeScript does seem to model that the simpler Record<K, U>[K] is assignable to U. For example:

    function foo<K extends `a${string}`>(k: K) {
        const a = {} as { b: 1 } & Record<K, string>
        const _a: Record<K, string> = a; // widen a
        const r = _a[k];
        r.toLowerCase();
    }
    

    Here _a is just a but its type has been widened from {b: 1} & Record<K, string> to Record<K, string>. Once you have that, then _a[k] can be assigned to string.

    Note that TS isn't always sound and definitely isn't complete, so generic type manipulations like these can end up working or failing in cases where they shouldn't. The widening of T & Record<K, U> to Record<K, U> is usually safe, but TS's type system has some holes that break equivalences in edge cases. So if all else fails and you've convinced yourself that what you're doing is safe, there's nothing wrong with using a type assertion like const r = a[k] as string.

    Playground link to code