0

I have a system that takes key:object pairs and then lets you get them later in the application.

The problem is having this index signature on breaks the typing because it allows any key at all to exist so you can't type the get() requests.

A summarized snippet of the application and problem is below.

I have tried removing this index signature which is a problem because then the setting[key].value in class Configuration does not exist. I have tried removing the base Settings interface but that basically breaks any change you have of typing things. I have tried quite a few helper functions and guards to try to force the application to realize that it is a setting. I am not at the point where I will have to accept no typing on the get method.

class Setting {
  constructor(
    public value: number,
    private locked: boolean
  ) { }
}

class Settings {
  [key: string]: Setting; // Comment me out: errors (but typing), leave me in: no errors (no typing)
}

interface SettingsCountryRank extends Settings {
  taiwan: Setting;
  china: Setting;
}

class Configuration<TSettings extends Settings> {
  settings: TSettings;

  public get<U extends keyof TSettings>(key: U): TSettings[U]['value'] {
    return this.settings[key].value;
  }

  constructor(settings: TSettings) {
    this.settings = settings;
  }
}

function main(){
  const test = new Configuration<SettingsCountryRank>({ taiwan: new Setting(1, true), china: new Setting(2, false) });

  test.get('china')
  test.get('notathing');
}

When you toggle on and off the line in Settings you can see the problem.

sailingonsound
  • 103
  • 1
  • 7

1 Answers1

2

I think instead of using an index signature you should use a mapped type whose keys are constrained to known values. I'll be using the Record<K, V> mapped type as defined in the standard library.

The important bit is to use recursive constraints of the form T extends Record<keyof T, Setting>, which ensures that all properties of T must have the type Setting, but which does not put any constraints on the keys (an index signature says constrains the keys to "all possible string values", which is not what you want):

// Settings<T> has the same keys as T, and the values are all Setting
type Settings<T> = Record<keyof T, Setting>;

// constraining SettingsCountryRank to Settings<SettingsCountryRank> is
// makes sure that every declared property has type Setting:
interface SettingsCountryRank extends Settings<SettingsCountryRank> {
    taiwan: Setting;
    china: Setting;
    // oopsie: string; // uncomment this to see an error
}

// constraining TSettings to Settings<TSettings> lets the compiler know that
// all properties of TSettings are of type Setting
class Configuration<TSettings extends Settings<TSettings>> {
    settings: TSettings;

    public get<U extends keyof TSettings>(key: U): TSettings[U]['value'] {
        return this.settings[key].value;
    }

    constructor(settings: TSettings) {
        this.settings = settings;
    }
}

function main() {
    const test = new Configuration<SettingsCountryRank>({ taiwan: new Setting(1, true), china: new Setting(2, false) });
    test.get('china')
    test.get('notathing'); // error as expected
}

This works as you expect, I think. Hope that helps; good luck!

jcalz
  • 125,133
  • 11
  • 145
  • 170