skip to Main Content

Hello I bumped into a situation where I want to conditionally type my Component by its props, but TypeScript works terribly with generics in that case.

Could you explain to me why specifically with Component, TypeScript works bad, but if I try to create a variable const test:Props<{variant:"link"}>, it will infer type correctly?

import React from 'react';

type BaseProps = {
  variant: 'close' | 'link' | 'dropdown';
  text: string;
};

type CloseButtonProps = BaseProps & {
  onClose: () => void;
  onClick: () => void;
};

type LinkButtonProps = BaseProps & {
  href: string;
  isExternal?: boolean;
};

type DropdownButtonProps = BaseProps & {
  content: string;
};

type Props<T extends BaseProps> = T['variant'] extends 'close'
  ? CloseButtonProps
  : T['variant'] extends 'link'
    ? LinkButtonProps
    : DropdownButtonProps;

const MenuItem = <T extends BaseProps>(props: Props<T>) => {
  return null;
};

const element = <MenuItem variant="link" href="test" text="test"/>

As you can see, element cannot have href prop because it falls to DropdownProps, why?

TypeScript playground

2

Answers


  1. In your case you can simply use a Discriminated Union, instead of chained Conditional Types:

    type BaseProps = {
      text: string;
    };
    
    type CloseButtonProps = BaseProps & {
      variant: 'close'; // Discrimant property with a unique literal value type in each member of the Union
      onClose: () => void;
      onClick: () => void;
    };
    
    type LinkButtonProps = BaseProps & {
      variant: 'link';
      href: string;
      isExternal?: boolean;
    };
    
    type DropdownButtonProps = BaseProps & {
      variant: 'dropdown';
      content: string;
    };
    

    You can now directly use the Union, without resorting to generic type parameter inference:

    const MenuItem = (props: CloseButtonProps | LinkButtonProps | DropdownButtonProps) => {
      return null;
    };
    
    <>
      <MenuItem variant="link" href="test" text="test" />{/* Okay */}
      <MenuItem variant="dropdown" href="test" content="foo" text="test" />{/* Property 'href' does not exist on type 'IntrinsicAttributes & BaseProps & { variant: "dropdown"; content: string; }'. */}
      {/*                          ~~~~ */}
      <MenuItem variant="dropdown" content="foo" text="test" />{/* Okay */}
    </>
    

    Playground Link


    Note that in your example, const test:Props<{variant:"link"}> works because you explicitly specify the generic type parameter; it is not "inferred"; but then the conditional types indeed work.

    If we explicitly specify the generic type parameter in the JSX in a similar way, it works as well:

    // Okay if explicitly specifying the generic type parameter
    <MenuItem<{ variant: 'link' } & BaseProps> variant="link" href="test" text="test" />
    

    But even outside JSX, should let TS teying to infer the generic type parameter, it fails as well:

    // Error, does not infer correctly:
    // Object literal may only specify known properties, and 'href' does not exist in type 'DropdownButtonProps'.
    MenuItem({ variant: 'link', href: 'test', text: 'test' })
    //                          ~~~~ 
    
    Login or Signup to reply.
  2. Simply Tweaking your Props and MenuItem so that the generic type has ‘close’ | ‘link’ | ‘dropdown’ as parameters instead of complete BaseProps object will make it work:

    type Props<T extends BaseProps['variant']> = T extends 'close'
      ? CloseButtonProps
      : T extends 'link'
        ? LinkButtonProps
        : DropdownButtonProps;
    
    const MenuItem = <T extends BaseProps>(props: Props<T['variant']>) => {
      return null;
    };
    

    Playground

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search