typescripttypescript-typingstypescript-generics

Typescript dynamic object keys with defined values


I am encountering an issue trying to make typescript recognise the keys of a javascript object for me, while enforcing each key's value type because I want to create a typeof the keys of the object, so I can't just create a regular type MyObject = { [key: string]: <insert type> }.

Imagine an object myobject where I extract the keys of it like:

const myobject = {
  foo: {},
  bar: {}
};

type MyObjectKeys = keyof typeof myobject; // 'foo' | 'bar'

How can I add type definitions to the values of the keys, while still being able to extract/inherit the definitions of the keys? If I do something like this, then I will no longer be able to extract the exact keys of the object, but only the type (string):

type MyObject = { [key: string]: { value: boolean }}
const myobject = {
  foo: { value: true },
  bar: { value: false }
};

type MyObjectKeys = keyof typeof myobject; // string

I figured that I could achieve this by creating a helper function like:

function enforceObjectType<T extends MyObject>(o: T) {
    return Object.freeze(o);
}
const myobject = enforceObjectType({
  foo: {},
  bar: {}
});

But I'd prefer to define a clear type for it, without having to pollute the code, writing functions only related to types. Is there a way to allow a set of strings as keys of a type without repetition?

The purpose of this is to get TypeScript to help pointing out the right object keys like (the real usage is a bit more complex, so I hope this describes it well enough):

type MyObjectKeys = keyof typeof myobject; // string
function getMyObjectValue(key: MyObjectKeys) {
   const objectValue = myobject[key];
}

// suggest all available keys, while showing an error for unknown keys
getMyObjectValue('foo'); // success
getMyObjectValue('bar'); // success 
getMyObjectValue('unknown'); // failure

Wrap up: I want to define an object as const (in fact with Object.freeze) and be able to:

  1. Extract the exact keys of the object (without having to type a definition of each key).
  2. Define the type of each key, without overwriting the keys to string instead of what they are - like 'foo' | 'bar'.

Complete example

type GameObj = { skillLevel: EnumOfSkillLevels }; // ADD to each key.
const GAMES_OBJECT = Object.freeze({
   wow: { skillLevel: 'average' },
   csgo: { skillLevel 'good' }
)};

type GamesObjectKeys = keyof typeof GAMES_OBJECT;

function getSkillLevel(key: GamesObjectKeys) {
  return GAMES_OBJECT[key]
}

getSkillLevel('wow') // Get the actual wow object
getSkillLevel('unknown') // Get an error because the object does not contain this.

In accordance to above, I can't do the following because that will overwrite the known keys to just any string:

type GameObj = { [key: string]: skillLevel: EnumOfSkillLevels };
const GAMES_OBJECT: GameObj = Object.freeze({
   wow: { skillLevel: 'average' },
   csgo: { skillLevel 'good' }
)};

type GamesObjectKeys = keyof typeof GAMES_OBJECT;

function getSkillLevel(key: GamesObjectKeys) {
  return GAMES_OBJECT[key]
}

getSkillLevel('wow') // Does return wow object, but gives me no real-time TS help
getSkillLevel('unknown') // Does not give me a TS error

Another example: See this gist for example and copy it to typescript playground if you want to change the code


Solution

  • While I have not found a way to completely avoid creating a javascript function to solve this (also told that might not be possible at all, at this moment), I have found what I believe is an acceptable solution:

    type GameInfo = { [key: string]: { skillLevel: 'good' | 'average' | 'bad' }}
    
    type EnforceObjectType<T> = <V extends T>(v: V) => V;
    const enforceObjectType: EnforceObjectType<GameInfo> = v => v;
    
    const GAMES2 = enforceObjectType({
      CSGO: {
        skillLevel: 'good',
      },
      WOW: {
        skillLevel: 'average'
      },
      DOTA: {
        // Compile error - missing property skillLevel
      }
    });
    
    type GameKey2 = keyof typeof GAMES2;
    
    function getGameInfo2(key: GameKey2) {
      return GAMES2[key];
    }
    
    getGameInfo2('WOW');
    getGameInfo2('CSGO');
    getGameInfo2('unknown') // COMPILE ERROR HERE
    

    This way we get:

    1. Compile errors on missing properties.
    2. Autocomplete of missing properties.
    3. Able to extract the exact keys defined in the object, without having to define those elsewhere, e.g. in an enum (duplicating it).

    I have updated my Gist to include this example and you might be able to see it in practice on typescript playground.