0

Consider the following TypeScript code snippet, which compiles and works successfully:

class UserSettings {
  [key: string]: boolean;

  setting1: boolean = false;
  setting2: boolean = false;
  setting3: boolean = false;
  otherOtherSetting: boolean = true;
}

const settings = new UserSettings();

const checkSettingChanged = (setting: string, value: boolean) => {
  if (value !== settings[setting]) {
    console.log(`The setting of ${setting} changed, updating it!`);
    settings[setting] = value;
  }
};

However, this code sucks, because by specifying [key: string]: boolean in the class, we lose the class safety. In other words, the following code will compile successfully but give a runtime error:

function someOtherFunction() {
  // Oops, I forgot to specify setting 4 in the class definition above,
  // so this code should fail to compile, but TypeScript doesn't care,
  // and now I will get a stupid runtime error
  if (settings.setting4) {
    console.log('Setting 4 is enabled!');
  }
}

Ok. So can I remove the [key: string]: boolean in the class definition? Let's try and see what happens:

class UserSettings {
  // This is just a "normal" TypeScript class now
  setting1: boolean = false;
  setting2: boolean = false;
  setting3: boolean = false;
  otherOtherSetting: boolean = true;
}

const settings = new UserSettings();

const checkSettingChanged = (setting: string, value: boolean) => {
  // Hopefully, adding this runtime check should satisfy the TypeScript compiler
  if (!(setting in settings)) {
    throw new Error(`The setting of ${setting} does not exist in the UserSettings class.`);
  }

  if (value !== settings[setting]) {
    console.log(`The setting of ${setting} changed, updating it!`);
    settings[setting] = value;
  }
};

This code does not satisfy the TypeScript compiler. It fails on the value !== settings[setting] part, throwing the error of:

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'UserSettings'.
  No index signature with a parameter of type 'string' was found on type 'UserSettings'.

What gives? How can I construct a runtime check that satisfies the TypeScript compiler?

James
  • 1,394
  • 2
  • 21
  • 31

1 Answers1

1

In will not work as a type guard to assert a string is a key of a type. You can create a custom type guard that will perform the narrowing:

class UserSettings {
  // This is just a "normal" TypeScript class now
  setting1: boolean = false;
  setting2: boolean = false;
  setting3: boolean = false;
  otherOtherSetting: boolean = true;
}

const settings = new UserSettings();
function isKeyOf<T>(p: PropertyKey, target: T): p is keyof T {
    return p in target;
}

const checkSettingChanged = (setting: string, value: boolean) => {
  // Hopefully, adding this runtime check should satisfy the TypeScript compiler
  if (!isKeyOf(setting, settings)) {
    throw new Error(`The setting of ${setting} does not exist in the UserSettings class.`);
  }

  if (value !== settings[setting]) {
    console.log(`The setting of ${setting} changed, updating it!`);
    settings[setting] = value;
  }
};

[Playground Link](Playground Link)

Not however that this is not fully type safe. Basic OOP dictates that you should be able to have in settings an instance of a class derived from UserSettings, which might not have all boolean values. You can read more about this issue here as it applies to Object.keys, but the same reasoning hold here. While in the general case it is not type safe, you can still use it with the specified caveat.

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • That code fails to compile, perhaps because I use TypeScript in strict mode. It complains: `'target' is declared but its value is never read` – James Apr 30 '20 at 15:25
  • @James fixed it, meant to use the target instead of settings – Titian Cernicova-Dragomir Apr 30 '20 at 15:27
  • Thank you. Can you explain the following? https://www.typescriptlang.org/play/index.html#code/MYGwhgzhAECqEFMBOBlBAXdBLAdgcxgG8AoaaRTXPARgC5oAjAeyZATB2gF5oAzMEIgDcpchmz4ATPRwBXALYNk3aAAYRAX2LFgTHBHRjK+GDxwIA7nESpxVCAAoAlCN6ycwbHuhYIAaQQATwB5XgAeABUAPgcAB3oABSQmWOR0QIDAgBpodDAkPAx6CKd6WJ8YAGsgpl5oCOgSMjIkDFkkTnLcXPzC9E1tXX1DYAALBGBKtGM8AGFRjkKAExUHCgk8egMkKhyANwFZBHpmVnZOAB9oOUVkJ24oxtEsOocAQl9M0LW7fBz1+xOe5NZroUbJKzmKwAUSQySQDgABhFxkYNtBatAACSEAH4DTQJZMBAwHBMQwIAAevkM3TBCGsyGmGxgoEgEAAdIiXKItM9XgcQEdoG8uDw8QQANoSgC6wNEZCGEDOHJATDwSJRDIlGLqOIlBLGiwQSxysliSzA6Kw6De3JEzTR9mlvzwMpUgqODugWg0IiAA – James Apr 30 '20 at 15:32
  • @James that is no longer safe, you are assigning a `boolean | number` to a key that must be either `boolean` or `number`. Nothing is preventing this call: `checkSettingChanged('setting2', true)` – Titian Cernicova-Dragomir Apr 30 '20 at 15:37
  • I see. Thank you very much for your help. – James Apr 30 '20 at 15:39