Complex conditional types in typescript, narrowing, and `extends` keyword

149 Views Asked by At

I have searched a lot and read several question to find a solution to my problem but in no vain. Can you help me?!

What I don't understand is that when a type extends boolean and put in the if statement condition it should be narrowed to true but TypeScript has a different idea in some situation:

import Select from "react-select";

export interface Option<Value> {
  readonly value: Value;
  readonly label?: string;
  readonly isDisabled?: boolean;
  readonly isFixed?: boolean;
}

export type PropValue<Value, IsMulti extends boolean> = IsMulti extends true
  ? Value[]
  : Value;

const ComboBox = <Value, IsMulti extends boolean>(props: {
  value?: PropValue<Value, IsMulti>;
  options: Option<Value>[];
  isMulti: IsMulti;
}) => {
  const { value, isMulti, options } = props;
  const mapValue = (x?: PropValue<Value, IsMulti>) => {
    if (!x) return undefined;
    if (isMulti) {
      isMulti;
      // ??? why isMulti is not of type `true` but rather still `extends boolean`
      // ??? x should be an array but doesn't seem to be narrowed as well
      return options.filter(({ value }) => x.includes(value));
    }
  };

  return <Select value={mapValue(value)} isMulti={isMulti} />;
};

A more simple scenario will work as expected:

function experimenting<T extends boolean>(x: boolean, y: T) {
  if (x) {
    x; //: true
  }

  if (y) {
    y; //: true
  }
}
  • Can you explain isMulti in the first scenario didn't get narrowed to just true?
  • How to fix the code above so that both isMulti and x are narrowed.
2

There are 2 best solutions below

2
wonderflame On

T extends boolean doesn't mean that T is equal to boolean. The extends clause just means that T is a subset of the boolean and the compiler isn't able to narrow the type to just true since it doesn't know the exact type of it.

Example: never is an empty set and the subsets of the boolean are true | false | never, since an empty set is also a subset. Thus, we can pass never to the function that expects a generic parameter that extends boolean:

const func = <T extends boolean>() => {};

type A = never extends boolean ? true : false; // true
type B = boolean extends never ? true : false; // false

func<never>() // no error
func<boolean>() // no error
func<string>() // error

3
Dream Echo On

You can instead of extending Boolean, extend true | undefined on the ComboBox and PropValue, and a default type undefined so that you don't have to pass isMulti={undefined} And in the PropValue you can narrow it down using NonNullable to check if it's true

import React from 'react';
import Select from 'react-select';

export interface Option<Value> {
  readonly value: Value;
  readonly label?: string;
  readonly isDisabled?: boolean;
  readonly isFixed?: boolean;
}

export type PropValue<
  Value,
  IsMulti extends true | undefined
  //  ^^^^^^^^^^^^^^^^^^^^
> = IsMulti extends NonNullable<IsMulti> ? Value[] : Value;
//  ^^^^^^^^^^^^^^^^^^^^^^^^

const ComboBox = <Value, IsMulti extends true | undefined = undefined>(props: {
  //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  value?: PropValue<Value, IsMulti>;
  options: Option<Value>[];
  isMulti?: IsMulti;
}) => {
  const { value, isMulti, options } = props;
  const mapValue = (x?: PropValue<Value, IsMulti>) => {
    if (!x) return undefined;
    if (isMulti && Array.isArray(x)) {
                //  ^^^^^^^^^^^^^^^
      return options.filter(({ value }) => x.includes(value));
    }
  };

  return <Select value={mapValue(value)} isMulti={isMulti} />;
};

export const Test1 = () => {
  return (
    <ComboBox
      value={1}
      options={[
        {
          value: 1,
          label: 'label 1',
        },
        {
          value: 2,
          label: 'label 2',
        },
        {
          value: 3,
          label: 'label 3',
        },
      ]}
    />
  );
};

export const Test2 = () => {
  return (
    <ComboBox
      isMulti
      value={[1, 2, 3]}
      options={[
        {
          value: 1,
          label: 'label 1',
        },
        {
          value: 2,
          label: 'label 2',
        },
        {
          value: 3,
          label: 'label 3',
        },
      ]}
    />
  );
};

Also, a union for props would work

const ComboBox = <Value,>(props: {
  // ...
  value: Value[];
  isMulti: true;
} | {
  // ...
  value: Value;
  isMulti?: false;
}) => {