typescript template literal as interface key

Viewed 6176

Lets say that I want to create a object contain multiple items in typescript as below:

const obj: Items = {
  item1: 'foo',
  item2: 'bar',
  item3: 'baz',
}

How should I declare my Items type so that it's compatible with any number of items? I tried the following with template literals from Typescript 4.1 and it doesn't seem to work:

interface Items {
  [P: `array${number}`]: any;
}

Is it possible to declare a type like this?

6 Answers

UPDATE FOR TS4.4+

TypeScript 4.4 will support index signatures that include pattern template literals, as implemented in microsoft/TypeScript#44512. You will then be able to declare Items as a specific type, like this:

interface Items {
  [key: `item${number}`]: any;
}

And you can verify that it works as desired:

const obj: Items = {
  item1: 'foo',
  item2: 'bar',
  item2021: 'baz',
  item3: 'qux',
};

const objBad: Items = {
  item1: 'foo',
  item2: 'bar',
  itemMMXXI: 'baz', // error!
  //  ~~~~~~~~~ <--
  //  Object literal may only specify known properties,
  //  and 'itemMMXXI' does not exist in type 'Items'
  item3: 'qux'
};

Playground link to code


ANSWER FOR TS4.1-4.3

Pattern template literals of the form `item${number}` (as implemented in microsoft/TypeScript#40598) are not currently allowed as key types, as of TypeScript 4.1.

For now there is no specific type corresponding to your desired Items type. Instead, you could represent it as a constraint on a type and write a helper function asItems() which will only accept inputs that adhere to the constraint:

const asItems = <K extends PropertyKey>(
    obj: { [P in K]: P extends `item${number}` ? any : never }
) => obj;

Each key of the passed-in obj will be checked for whether it is assignable to `item${number}`. If so, the property type is any, and if not, the property type is never. That will tend to cause errors on any offending property:

const obj = asItems({
    item1: 'foo',
    item2: 'bar',
    item2021: 'baz',
    item3: 'qux',
}); // okay

const objBad = asItems({
    item1: 'foo',
    item2: 'bar',
    itemMMXXI: 'baz', // error!
//  ~~~~~~~~~ <-- Type 'string' is not assignable to type 'never'
    item3: 'qux'
});

Playground link to code

I ended up with an approach that first constructs a tuple of a fixed length(since its keys cannot be infinitely long), and then iterate through them while filtering out non-numeric keys, and used it to construct the Items type. One caveat however, as stated above, is that the number cannot exceed a limit (which happens to be 44), nevertheless it was enough for my use case, so I'm pretty satisfied.

// https://github.com/Microsoft/TypeScript/issues/26223#issuecomment-513116547
type PushFront<TailT extends any[], FrontT> = ((...args: [FrontT, ...TailT]) => any) extends (...tuple: infer TupleT) => any ? TupleT : never;
type Tuple<ElementT, LengthT extends number, OutputT extends any[] = []> = {
    0: OutputT;
    1: Tuple<ElementT, LengthT, PushFront<OutputT, ElementT>>;
}[OutputT['length'] extends LengthT ? 0 : 1];
const N = 44;
// N larger than 44 seems to exceed recursion limit
// const N = 45;
type NameN<Name extends string, T> = {
    [P in keyof Tuple<any, typeof N> as P extends `${number}` ? `${Name}${P}` : never]: T;
};

type Items = NameN<'item', any>

Playground Link

If you have a reasonable maximum on the number of properties you can accept, you might want to just have the compiler calculate these properties for you. For example, the following will generate item1 through item249:

type Foo = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // 10
type Bar = [...Foo, ...Foo, ...Foo, ...Foo, ...Foo]; // 50
type Baz = [...Bar, ...Bar, ...Bar, ...Bar, ...Bar]; // 250
type ItemNames = `item${Exclude<keyof Baz, '0' | keyof any[]>}`
// item1 through item249
type ItemProps = { [K in ItemNames]?: any };
interface Items extends ItemProps {
}

const obj: Items = {
    item1: 'foo',
    item2: 'bar',
    item3: 'baz',
} // okay


const objBad: Items = {
    item1: 'foo',
    item2: 'bar',
    itemMMXXI: 'baz', // error!
    //~~~~~~~~~~~~~ <-- Object literal may only specify known properties, and 'itemMMXXI'
    // does not exist in type Items
};

You can scale this up to maximums less than about 10,000 before the compiler starts complaining:

type Foo = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // 10
type Bar = [...Foo, ...Foo, ...Foo, ...Foo, ...Foo]; // 50
type Baz = [...Bar, ...Bar, ...Bar, ...Bar, ...Bar]; // 250
type Qux = [...Baz, ...Baz, ...Baz, ...Baz, ...Baz]; // 1250
type Quux = [...Qux, ...Qux, ...Qux, ...Qux, ...Qux]; // 6250
// type Suux = [...Quux, ...Quux]; // error: 
//   Type produces a tuple type that is too large to represent.

type ItemNames = `item${Exclude<keyof Quux, '0' | keyof any[]>}`
// item1 through item6249

Playground link to code

It isn't possible to declare a type exactly like that. Danie A's answer is the closest you can really get, but then you'll accept all strings. If you know there's an upper bound on how many numbers you need and you're willing to type them all in, you could do something like this:

type n = '1' | '2' | '3' | '4' | '5'; // you get the idea
type key = `item${n}`;

I'm sure you already knew you could do that though.

Here is my solution :

type Item<n extends number> = `item${n}`
type Items<n extends number[]> = {
    [key in Item<n[number]>]: any
}

const goodObj: Items<[1, 2, 2021, 3]> = {
    item1: 'foo',
    item2: 'bar',
    item2021: 'baz',
    item3: 'qux', 
}; // okay

const badObj: Items<[1, 2, 2021, 3]> = {
    item1: 'foo',
    item2: 'bar',
    itemMMXXI: 'baz', // error!
    //  ~~~~~~~~~ <-- Type '{ item1: string; item2: string; itemMMXXI: string; item3: string; }' is not assignable to type 'Items<[1, 2, 2021, 3]>'.
    //                Object literal may only specify known properties, and 'itemMMXXI' does not exist in type 'Items<[1, 2, 2021, 3]>'
    item3: 'qux'
};

Related