1

I have the following (simplified) code:

type GetInput<U> = {
  defaultValue?: U
}

const getBool = (input: GetInput<boolean>) => {
  return input.defaultValue ?? true
}

const getNumber = (input: GetInput<number>) => {
  return input.defaultValue ?? 3;
}

type GetFunc<U> = (input: GetInput<U>) => U;

type ToType<T extends 'boolean' | 'number'> =
  T extends 'boolean'
    ? boolean
    : T extends 'number'
    ? number
    : never;

type GlobalGetInput<
  T extends 'boolean' | 'number',
  U extends ToType<T>
> = {
  type: T;
  defaultValue?: U;
};

const get = <T extends 'boolean' | 'number', U extends ToType<T>>(input: GlobalGetInput<T, U>) => {
  let func: GetFunc<U>;
  switch (input.type) {
    case 'boolean':
      func = getBool;
      break;
    case 'number':
      func = getNumber;
      break;
    default:
      throw new Error('Invalid type')
  }
  return func({ defaultValue: input.defaultValue })
} 

get({ type: 'boolean', defaultValue: true })

Playground

It works correctly when run, but the typing fails in func = getBool; with

Type '(input: GetInput<boolean>) => boolean' is not assignable to type 'GetFunc<U>'.
  Types of parameters 'input' and 'input' are incompatible.
    Type 'GetInput<U>' is not assignable to type 'GetInput<boolean>'.
      Type 'U' is not assignable to type 'boolean'.
        Type 'ToType<T>' is not assignable to type 'boolean'.
          Type 'boolean | (T extends "number" ? number : never)' is not assignable to type 'boolean'.
            Type 'T extends "number" ? number : never' is not assignable to type 'boolean'.
              Type 'number' is not assignable to type 'boolean'.
                Type 'number' is not assignable to type 'boolean'.
                  Type 'number | boolean' is not assignable to type 'boolean'.
                    Type 'number' is not assignable to type 'boolean'.

This happens because I do not know how to tell the compiler that U can only be of type boolean if the string boolean is passed in the type parameter. If it was an union of objects, we could discriminate using a static property but with a union of scalars I have no clue.

Thanks!

Sytten
  • 322
  • 1
  • 8

1 Answers1

1

This happens because of variance. TypeScript is preventing you from doing a mistake. Look at the following example:

// you can't assign a function with a broader result to a narrower one
declare let fnA: () => 1 | 2
declare let fnB: () => 1
fnB = fnA

// you can't assign a function with narrower params to a broader one
declare let fnC: (a: 1 | 2) => 3
declare let fnD: (a: 1) => 3
fnC = fnD

In the last example, because you are assigning fnD to fnC, you are meaning that fnD can handle the same input as fnC - but that's not true. This could lead to a runtime error.

When you see such error messages, remember that TypeScript is just warning you that you are not complying with its variance rules. If you want to learn more about this, there's an excellent presentation about it and this other post.

In your case, TypeScript is not able to see that you are handling things correctly and still sees your operation as unsafe. Because you know what you're doing, you'll need to tell TypeScript that it's fine. Be careful.

const get = <T extends 'boolean' | 'number', U extends ToType<T>>(input: GlobalGetInput<T, U>) => {
  let func: GetFunc<U>;

  switch (input.type) {
    case 'boolean':
      func = getBool as any as GetFunc<U>;
      break;
    case 'number':
      func = getNumber as any as GetFunc<U>;
      break;
    default:
      throw new Error('Invalid type')
  }
  return func({ defaultValue: input.defaultValue })
} 

I usually use any to strongly signal that this is an override, but you could as well unknown, it's up to you.

millsp
  • 1,259
  • 1
  • 10
  • 23
  • This is what I am doing currently, but it is still a shame that I can't tell TS that it is safe. Maybe I could switch GetFunc to use T instead and it would understand it as safe? – Sytten Jul 21 '21 at 15:42