Search code examples
typescript

Conditional type plus index access not working as expected


I am trying to build an utility function and type it properly. This function will read from local storage.

Here are the types:

type Theme = "dark" | "light";

// the settings that are allowed to be stored/read
type SettingName = "theme" | "test";

// describes the value type of each setting
type SettingValue<T extends SettingName> = {
  theme: Theme;
  test: boolean;
}[T];

// defining input options
type SettingOptions = {
  // the default value to return if no value is found in localStorage.
  // can be one of the possible values of the setting or null
  defaultValue?: SettingValue<SettingName> | null;
};

// the output type I am expecting depends on the options
// returning `null` is allowed only if `defaultValue` is either missing or passed as `null`
type ReadSettingResult<O extends SettingOptions> =
  O["defaultValue"] extends null | undefined
    ? SettingValue<SettingName> | null
    : SettingValue<SettingName>;

And here is how I am using the types on the utility function:

export const readSetting = (
  setting: SettingName,
  opts: SettingOptions = { defaultValue: null }
): ReadSettingResult<typeof opts> => {
  const v = localStorage.getItem(setting as unknown as string);
  return v
    ? (v as SettingValue<typeof setting>)
    // following line gives me the error Type 'boolean | Theme | null' is not assignable to type 'boolean | Theme'. Type 'null' is not assignable to type 'boolean | Theme'.ts(2322)
    : (opts?.defaultValue ?? null); 
};

I was actually expecting the result type to accept null if defaultValue is null, as expressed (probably badly) in the ReadSettingResult type.

But if I create a variable of type ReadSettingResult, this type works as I am expecting.

// OK - a value is found
const res1: ReadSettingResult<{ defaultValue: null }> = "dark";
const res2: ReadSettingResult<{ defaultValue: null }> = true;

// OK - a value is not found, what is returned is the defaultValue
const res3: ReadSettingResult<{ defaultValue: null }> = null;

// KO - a value is not found, but defaultValue is not returned
// correctly errors with "Type 'null' is not assignable to type 'boolean | Theme'.ts(2322)""
const res4: ReadSettingResult<{ defaultValue: "light" }> = null;

So I don't understand what is going wrong. Do I need to cast opts?.defaultValue ?? null to ReadSettingResult or something similar? And if so, why? Is it a TypeScript limitation or, most probably, I am missing something here?

EDIT

Thanks for your comments. I spent a bit of time playing with the code according to what you guys said. First, typeofs of course are a mistake of mine. Then, I must admit I messed up also in my head the requirements for this code. So I edit the question to make things clearer.

What do I want to achieve is readSetting to try to read a setting's value and return null if not found. If a fallback is provided, such fallback value is returned when the setting is not found.

// OK - this may return null, as there is no fallback value
const res1: Theme | null = readSetting("theme");

// OK - this will never return null as a fallback value is provided
const res1: Theme = readSetting("theme", { fallbackValue: "dark" });

// KO - this may return null if value is not found, as there is no fallback value
// I am searching for a definition of the returned type such that TypeScript pretends a `Theme | null` here
const res3: Theme = readSetting("theme");

I eventually came up with this types, but still I am missing the goal. (A bit of renaming, also, to make things clearer.)

type SettingOptions<N extends SettingName> = {
  // I really don't need a `null` value here
  fallbackValue: SettingValue<N>;
};

// simplified a bit but still missing something
type ReadSettingResult<
  N extends SettingName,
  // O can be also undefined
  O extends SettingOptions<N> | undefined,
> = O extends SettingOptions<N> ? SettingValue<N> : SettingValue<N> | null;

With these new types, I get this error:

Type 'SettingValue | null' is not assignable to type 'ReadSettingResult<N, O>'. Type 'null' is not assignable to type 'ReadSettingResult<N, O>'.

Here is the updated function. I tried to leverage type assertions, but failed miserably.

const readSetting = <N extends SettingName, O extends SettingOptions<N>>(
  setting: N,
  opts?: O,
): ReadSettingResult<N, O> => {
  const v = localStorage.getItem(setting as unknown as string);
  if (v) {
    return (v as SettingValue<N>)
  }
  const hasF = hasFallback(opts)
  return hasF ? opts.fallbackValue : null;
// fails with the same error also without assertion when using
// return opts?.fallbackValue ?? null
};

function hasFallback<N extends SettingName>(opts?:SettingOptions<N>): opts is SettingOptions<N> {
  return !!opts
}

Solution

  • The main problem you're going to have with code like this is that the sort of control flow analysis which narrows opts?.fallbackValue from SettingValues[K] | null | undefined to SettingValues[K] or to null | undefined has no affect whatsoever on generic type parameters like O. So if your function returns a generic conditional type like O extends WWW ? XXX : YYY, you can check opts all you want, but O will be unaffected, and the value you return can't be verified to match the conditional type.

    There are a few longstanding open feature requests in GitHub to support this sort of code, like microsoft/TypeScript#33014 to re-constrain type parameters with control flow analysis, and microsoft/TypeScript#33912 to allow control flow analysis to match generic conditional types. These might be implemented sometime in the near future, but for now they're not part of the language and you need to work around it.

    The workarounds will all involve some sort of loosening of type safety, such as using type assertions to say "I'm returning null and it's okay to treat it as `O extends WWW ? XXX : YYY". And you need to be careful that the assertion is valid, because TypeScript can't follow it.


    That being said you can try to make the code as straightforward as possible so that assertions happen in the fewest places possible. Here's how one might do it for your example code:

    interface SettingValues {
      theme: Theme;
      test: boolean
    }
    
    type MaybeNull<O, K extends keyof SettingValues> =
      O extends [opts: { fallbackValue: SettingValues[K] }] ? never : null;
    
    const readSetting = <
      K extends keyof SettingValues,
      O extends [opts?: { fallbackValue?: SettingValues[K] | null }]>(
        setting: K, ...[opts]: O
      ): SettingValues[K] | MaybeNull<O, K> => {
      const v = localStorage.getItem(setting) as SettingValues[K] | null;
      if (v) { return (v) }
      return opts?.fallbackValue ?? (null as MaybeNull<O, K>);
    };
    

    The SettingValues change from your SettingValue is mostly cosmetic. The important part is that we've made readSetting generic in O, the type of the list of arguments after setting. This will be a tuple type of length 0 or 1 (and indeed it is constrained as such). Then MaybeNull<O, K> checks to see if that argument list type O actually contains a fallback value (which needs to be checked a few ways because you can leave out the whole argument, or pass the argument but leave out the fallbackValue property). If it does, then MaybeNull<O, K> is never (since the return value cannot be null). If not, then it is null (since the return value can be null). The readSetting() function returns the union SettingValues[K] | MaybeNull<O, K>. So if we return a SettingValues[K], it will always be accepted; the conditional type in MaybeNull<O, K> isn't even consulted. This lets if (v) { return v } work, and the left half of opts?.fallbackValue ?? null work. Only the null part needs to be asserted.


    Let's make sure this works from the caller's side:

    const res = readSetting("theme");
    //   ^? const res: Theme | null
    
    const res2 = readSetting("theme", { fallbackValue: "dark" });
    //    ^? const res2: theme
    
    const res3 = readSetting("test");
    //    ^? const res3: boolean | null
    
    const res4 = readSetting("test", { fallbackValue: "dark" }); // error!
    //                                 ~~~~~~~~~~~~~
    
    const res5 = readSetting("test", {})
    //    ^? const res5: boolean | null
    
    const res6 = readSetting("test", { fallbackValue: false });
    //    ^? const res6: boolean;
    

    Looks good!

    Playground link to code