Condtional type is not working properly as return type

109 Views Asked by At

I'm trying to make a settings system for my desktop app. having a config that includes several settings like add_while_paused, min_chars, and startup_settings (herein lies the problem)

So, using a class I'm trying to make several functionalities for settings like get_value, update_value, transform_value, etc...

I want to archive the following results:

const a = new Setting("add_while_paused");
a.get_value(); // true

const b = new Setting("startup_settings"); // Error: Property 'child_name' is missing in type ...
b.get_value();

const c = new Setting("startup_settings", "delay")
c.get_value(); // 53

But, get_value is throwing an error:

Type 'Config[BaseKey]' is not assignable to type 'HasChild<BaseKey, Config[BaseKey][ChildKey], Config[BaseKey]>'.
  Type 'string | number | boolean | StartupSettings' is not assignable to type 'HasChild<BaseKey, Config[BaseKey][ChildKey], Config[BaseKey]>'.
    Type 'string' is not assignable to type 'HasChild<BaseKey, Config[BaseKey][ChildKey], Config[BaseKey]>'.(2322)

This means that the returned value from the method is not matching the conditional type in the return type, but how is that? I'm pretty sure that the condition is right, I tested it and had no issues.

HasChild<"min_chars", true, false> // false
HasChild<"add_while_paused", true, false> // false
HasChild<"startup_settings", true, false> // true

Here's my reproducible example:

declare const $symbol: unique symbol;
interface NestedSetting {
  [$symbol]?: never;
}

interface StartupSettings extends NestedSetting {
  enabled: boolean;
  delay: number;
}

interface Config {
  add_while_paused: boolean;
  min_chars: number;
  note: string;
  startup_settings: StartupSettings;
}

type BaseKeys = keyof Config;

type HasChild<BK extends BaseKeys, True, False> = Config[BK] extends NestedSetting
  ? True
  : False;

type ChildKeysOf<BK extends BaseKeys> = HasChild<BK, keyof Config[BK], never>;

const config: Config = {
  add_while_paused: true,
  min_chars: 53,
  note: "Hello World!",
  startup_settings: { enabled: true, delay: 56 }
}

class Setting<BaseKey extends keyof Config, ChildKey extends ChildKeysOf<BaseKey> = never> {
  public base_name: BaseKey;
  public child_name: ChildKey;

  public constructor(base_name: BaseKey, child_name: ChildKey) {
    this.base_name = base_name;
    this.child_name = child_name;
  }

  public get_value(): HasChild<BaseKey, Config[BaseKey][ChildKey], Config[BaseKey]> {
    if (this.child_name) return config[this.base_name][this.child_name];
    return config[this.base_name]
  }
}

Also, please don't mind the NestedSetting type, I know I can just check for object instead but I did it like this because object would match functions and arrays, not just "true objects"

1

There are 1 best solutions below

1
On BEST ANSWER

Well, it might not be what you need, but I found a solution.

The problem lies in the that whilst HasChild determines the return type, you end up with something akin too:

public get_value(): number {
  if (this.child_name) {
    return config[this.base_name][this.child_name]
  }
  return config[this.base_name]
}

Typescript determines that the two return statements have differing types (for example, SystemSettings | number), and hence you get the compiler error.

The only way I can see to resolve this is to create two implementations, one for base keys, and another for child keys, but which share a common interface.

Not that this adds some verbosity, but it appears to be type safe (assuming you annotate the type of the new BaseSetting/ChildSetting as I have done. Equally, you could leave it inferred, or use a type guard. I actually think you might have problems passing these settings wrappers around because you'll lose the specific type in doing so. It's possible you are going to be getting/setting values from a UI that has an arbitrary type (like a string) and that you'll therefore need to sanitise the value, which means adding something to convert the value before the Settings implementation can get/set the value. This seems to invalidate the need for a wrapper class that groks type in this way unless you can rely on JS's coercion.

Notes:

  1. I simplified the base_name and child_name props
  2. I added set_value to flesh out the use case
  3. It compiles, but I'm not sure its going to solve your wider problem ;-)
declare const $symbol: unique symbol;
interface NestedSetting {
  [$symbol]?: never;
}

interface StartupSettings extends NestedSetting {
  enabled: boolean;
  delay: number;
}

interface Config {
  add_while_paused: boolean;
  min_chars: number;
  note: string;
  startup_settings: StartupSettings;
}

type BaseKeys = keyof Config;

type HasChild<BK extends BaseKeys, True, False> = Config[BK] extends NestedSetting
  ? True
  : False;

type ChildKeysOf<BK extends BaseKeys> = HasChild<BK, keyof Config[BK], never>;

const config: Config = {
  add_while_paused: true,
  min_chars: 53,
  note: "Hello World!",
  startup_settings: { enabled: true, delay: 56 }
}

interface Setting<T> {
  get_value(): T
  set_value(value: T): void
}

class BaseSetting<BaseKey extends keyof Config> implements Setting<Config[BaseKey]> {

  public constructor(public readonly base_name: BaseKey) {  }

  public get_value(): Config[BaseKey] {
    return config[this.base_name]
  }

  set_value(value: Config[BaseKey]): void {
    config[this.base_name] = value
  }
}

class ChildSetting<BaseKey extends keyof Config, ChildKey extends ChildKeysOf<BaseKey>> implements Setting<Config[BaseKey][ChildKey]> {

  public constructor(
    public readonly base_name: BaseKey, 
    public readonly child_name: ChildKey
  ) {  }

  public get_value(): Config[BaseKey][ChildKey] {
    return config[this.base_name][this.child_name]
  }

  set_value(value: Config[BaseKey][ChildKey]): void {
    config[this.base_name][this.child_name] = value
  }
}

const delay: Setting<number> = new ChildSetting("startup_settings", "delay")
delay.get_value()
delay.set_value(200)
const enabled: Setting<boolean> = new ChildSetting("startup_settings", "enabled")
enabled.get_value()
enabled.set_value(true)
const min_chars: Setting<number> = new BaseSetting("min_chars")
min_chars.get_value()
min_chars.set_value(10)