Как сохранить неявный ввод имени ключа как часть интерфейса, сохраняя при этом тип значения в TS?

#typescript

Вопрос:

Я бы очень хотел иметь возможность определить интерфейс / тип объекта, который позволяет мне определять, какими будут значения, но использовать ключи объекта в качестве набора возможных ключей в типе. Визуализируясь, я хочу найти альтернативу:

interface IElementType = {[key: string]: SpecialProperties}

и заменить чем-то вроде

interface IElementType = {[key: keyof elements]: SpecialProperties}

в примере, подобном

 const elements: IElementType = {
  pirate: {
    name: 'patchy',
    type: 'matey'
  },
  captain: {
    name: 'peggy',
    type: 'pegleg'
  },
  ...
}


// for reference
interface SpecialProperties {
  name: string,
  type: string
}
 

где keyof elements я придумываю то, что я хотел бы, чтобы было возможно (зная, что это не может сработать, так elements как это объект js), но надеюсь, что я просто неправильно об этом думаю. Мотивация здесь заключается в сохранении информации о типе / intellisense при импорте этой переменной в другую часть проекта. Затем я могу ввести elements.pirate , и ts будет знать, что этот ключ существует. Когда мы используем [key: string] , мы эффективно анонимизируем ключи в объекте и выбрасываем эту информацию. В отличие от того, когда мы определяем переменную без какого-либо явного типа, такого как

const elements = { ... }

где ts неявно вводит эту переменную с типом, который содержит явные ключи, содержащиеся в объекте, поэтому в приведенном выше случае тип будет точно соответствовать значению полного объекта.

Я надеюсь, что здесь есть какая-то средняя точка, где мы можем сохранить неявную типизацию имен ключей, но обеспечить, чтобы их значения были конкретными. Возможно ли это?

Ответ №1:

Ну, вы можете выразить то, что говорите, так keyof typeof elements как это совершенно допустимый тип, но вы не сможете определить IElementType в терминах elements и наоборот, не получая предупреждений о цикличности:

 type IElementType = Record<keyof typeof elements, SpecialProperties>;
//   ~~~~~~~~~~~~ <-- Type alias 'IElementType' circularly references itself.
const elements: IElementType = { /* .. */ }
//    ~~~~~~~~ <-- 'elements' is referenced directly or indirectly in its own type annotation.
 

Так что нам придется отказаться от этой идеи.


Если вы аннотируете elements тип, который еще не знает о конкретных ключах, например, {[k: string]: SpecialProperties} с помощью подписи индекса, то компилятор расширится elements до этого типа и забудет о конкретных ключах.

Поскольку вы хотели бы иметь возможность определять elements таким образом, чтобы компилятор проверял, не расширяя его, вы не можете его аннотировать. В microsoft/TypeScript#7481 есть запрос на функцию, запрашивающий какой-либо оператор типа, который действует как нерасширяющаяся аннотация, но на данный момент он не является частью языка.

Вместо этого вы можете определить универсальную вспомогательную функцию, которую вы используете вместо аннотации. Вы бы заменили const elements: IElementType = someValue на const elements = asElementType(someValue) , где asElementType() определяется как:

 const asElementType = <K extends PropertyKey>(t: { [P in K]: SpecialProperties }) => t;
 

Эта функция будет принимать входные данные только в том случае, если она может определить тип ключа K (который может быть объединением ключей) таким образом, чтобы входные данные содержали ключи K и значения SpecialProperties . Поскольку функция просто возвращает свои входные данные, тип возвращаемого значения будет таким же, как и его входные данные: тип объекта с некоторыми предполагаемыми типами ключей K и типами значений SpecialProperties .

Давайте посмотрим на это в действии:

 const elements = asElementType({
    pirate: {
        name: 'patchy',
        type: 'matey'
    },
    captain: {
        name: 'peggy',
        type: 'pegleg'
    },
});
/* const elements: {
    pirate: SpecialProperties;
    captain: SpecialProperties;
} */
 

Это работает; теперь elements есть тип {pirate: SpecialProperties, captain: SpecialProperties} . И если вы совершите ошибку, компилятор предупредит вас:

 const oops = asElementType({
    swabbie: { // error! Property 'type' is missing
        name: 'bilgey'
    }
})
 

таким образом, демонстрируя, что asElementType() действует аналогично интерфейсу с динамическими типами ключей, но статическими типами значений.

Ссылка на игровую площадку для кода

Комментарии:

1. Спасибо за обстоятельный ответ! Это имеет большой смысл и технически является именно тем, о чем я просил. Я действительно хотел бы, чтобы в ts был немного более чистый способ сделать это, но сейчас это, безусловно, подойдет. Без дублирования информации это кажется лучшим способом продвижения вперед.

Ответ №2:

Для этого примера…

 const elements: IElementType = {
  pirate: {
    name: 'patchy',
    type: 'matey'
  },
  captain: {
    name: 'peggy',
    type: 'pegleg'
  },
  ...
}
 

…для работы определите IElementType как универсальный интерфейс, но interface в этом случае использование объявления не работает. 🙁

 type IElementType<K extends string> = ​{
  ​[x in K]: SpecialProperties;
};
 

elements переменная будет иметь тип IElementType<'pirate' | 'captain'> для первых двух ключей.

Комментарии:

1. Спасибо за ответ здесь — это почти то, что я ищу, и это довольно простой способ настройки, но я надеялся добиться этого без необходимости дублировать информацию путем жесткого кодирования ключей

2. Если вы определили тип объекта или интерфейс, вы можете использовать keyof для получения его ключей, которые должны соответствовать параметру типа, например: IElementType<keyof Element> .