Question

How do I create a template literal type that includes the indexed access of a union type parameter?

I am trying to use a template literal type for the return type of a generic function which doesn't seem to work well when indexing on the type parameter.

type IPhone = {
  version: 1 | 2 | 3 | 4;
}

function getIPhoneName<T extends IPhone>(iPhone: T): `iPhone ${T['version']}` {
  return `iPhone ${iPhone.version}`; // Error "Type '"iPhone 1"' is not assignable to type '`iPhone ${T["version"]}`'"
}

function getIPhoneName2<T extends IPhone['version']>(iPhoneVersion: T): `iPhone ${T}` {
  return `iPhone ${iPhoneVersion}`; // Works
}

Fairly sure this issue has something to do with TypeScript not distributing the union, but unlike with conditionals, I don't see a way to force it to properly distribute. I also recognize that I could use as const without the return type (see below), but this isn't as nice when working with generic classes (where this problem also appears).

function getIPhoneName3<T extends IPhone>(iPhone: T) {
  return `iPhone ${iPhone.version}` as const;
}
 2  27  2
1 Jan 1970

Solution

 1

The problem is that when you index into a generic typed object like iPhone of type T with a specific key like "version", TypeScript widens the generic to its constraint. So instead of iPhone.version being of type T["version"] as you were hoping, it is actually seen as being of type IPhone["version"] which is just 1 | 2 | 3 | 4. The generic-ness is lost before the template literal type does any appending.

There's a feature request at microsoft/TypeScript#33181 to allow generics to stay generic when indexing with specific keys, but it doesn't have any real community engagement. Interested parties might want to make a pull request that demonstrates if it could be implemented without seriously harming compiler performance. For now, it's not part of the language.

Instead I'd say you might want to do this explicitly in multiple steps. If you annotate a variable as T['version'] you can assign iPhone.version to it, and then the compiler will allow you to do template literal stuff to it:

function getIPhoneName<T extends IPhone>(iPhone: T): `iPhone ${T['version']}` {
    const v: T['version'] = iPhone.version
    return `iPhone ${v}`; // okay
}

Playground link to code

2024-07-24
jcalz