Template literal containing keyof used as an interface key

Viewed 756

Given a simple interface:

interface Schedule {
  interval: "month" | "day";
  price: number;
}

I can construct a template-literal type (using ts4.4):

type PrefixedScheduleKey = `schedule__${keyof Schedule}`; // "schedule__interval" | "schedule__price"

Let's say there are a few of such interfaces and I'd like to merge (essentially flatten) their prefixed, template-literal keys to end up with:

interface Metadata {
  foo: string;
  bar: string;
  // computed 
  schedule__interval: "month" | "day";
  schedule__price: number;
  ...
}

How should I declare such interface keys?

interface Metadata {
  [key: PrefixedScheduleKey]: any
}

// throws 
// An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.(1337)

And

interface Metadata {
  [key in `schedule__${keyof Schedule}`]: any 
}

// leads to
// A computed property name must be of type 'string', 'number', 'symbol', or 'any'.(2464)
// Member '[key in `schedule__${keyof' implicitly has an 'any' type.(7008)

Here's the playground.

BTW, it was suggested on github to use type instead of interface but that won't work because my Metadata interface already extends another interface but I tried to keep the example simple.

1 Answers

This isn't possible to do directly on an interface, as documented here: Typescript: Mapped types with Interface

So far in TypeScript (as of version 2.7.2), union type signatures in interfaces are not allowed and instead mapped types should be used (as you correctly have).

Docs.

However, you can implement this with an intersection type between an arbitrary interface and a mapped type, as in the original GitHub issue you mentioned ("Moves other members to a separate object type that gets combined with an intersection type"). You can also use the as keyword to effectively inline your PrefixedScheduleKey template-literal type while retaining your Key long enough to refer to Schedule[Key]. There's no reason you couldn't do the same with other prefixed types, either in the same mapped metadata type or in adjacent ones that you intersect with later.

For reference, key is capitalized to Key when used as a type variable in mapped types, as on the mapped types handbook page.

interface BareMetadata /* extends ArbitraryType */ {
  foo: string;
  bar: string;
}

type ScheduleMetadata = {
  [Key in keyof Schedule as `schedule__${Key}`]: Schedule[Key];
}

type MergedMetadata = BareMetadata & ScheduleMetadata;

const example: MergedMetadata = {
  foo: "one",
  bar: "two",
  schedule__interval: "month",
  schedule__price: 9.99
};

Playground Link

You can even have the interface extend an object-like type, which might help you avoid the explicit intersection:

interface MergedMetadata extends BareMetadata, ScheduleMetadata {}
// or
interface Metadata extends ScheduleMetadata {
  foo: string;
  bar: string;
}

Playground Link

Related