Can Generic JSX.Elements work in Typescript

Viewed 703

I am quite new to Typescript generics. Right now, I have to merge different Components from old React-Projects into a Typescript Component library. One of those old Components are SurveyComponents, containing things like Dropdown etc.

What i did before:

Previously we could dynamically render them with the following Code:

const [optionValue, setOptionValue] = useState<Option<string>>({ value:'', id:'' })
const [textValue,   setTextValue]   = useState<Option<string>>({ value:'', id:'' })
const [ratingValue, setRatingValue] = useState<Option<number>>({ value: 0, id:'' })
const [arrValues,   setArrValues]   = useState<Option<string>[]>([])

<Question Component={Radio}    value={optionValue} setValue={setOptionValue} options={options} maxColumns={3}  title={'Radio:'} />
<Question Component={Checkbox} value={arrValues}   setValue={setArrValues}   options={options} maxColumns={3}  title={'Checkbox:'} />
<Question Component={Dropdown} value={optionValue} setValue={setOptionValue} options={options} Icon={WarnIcon} title={'Dropdown:'} />
<Question Component={Rating}   value={ratingValue} setValue={setRatingValue} Icon={WarnIcon}   max={3}         title={'Rating:'}  />
<Question Component={Text}     value={textValue}   setValue={setTextValue}                                     title={'Textfield:'}/>
<Question Component={Text}     value={textValue}   setValue={setTextValue}   area                              title={'Textarea:'} />

Inside of the Questioncomponent it was used as follows:

const Question = ({ Component, description, title, ...props }:QuestionProps) => {
  return (
    <div className={'question-wrapper'}>
      <div className={'question-title'}>{title}</div>
      <div className={'question-description'}>{description}</div>
      <div className={'question-component'}>
        <Component {...props}/>
      </div>
    </div>
  )
}
export default Question

This works completely fine, so it should work in Typescript aswell?

One of many approaches in Typescript:

export const SurveyComponents = [
  Radio,
  Checkbox,
  Text,
  Dropdown,
  Rating
] as const

export type SurveyComponentTypes = typeof SurveyComponents[number]

export interface QuestionProps<T> {
  Component: T
  title: string
  description?: string
}

const Question = <T extends SurveyComponentTypes>({ Component, description, title, ...props }:QuestionProps<T>) => {
  return (
    <div className={'question-wrapper'}>
      <div className={'question-title'}>{title}</div>
      <div className={'question-description'}>{description}</div>
      <div className={'question-component'}>
        <Component {...props}/>
      </div>
    </div>
  )
}
export default Question

This sadly gives me the following Error:

TS2322: Type '{}' is not assignable to type 'LibraryManagedAttributes '.

If i remove the <T extends SurveyComponentTypes> and just use <T,>

it returns this Error:

TS2604: JSX element type 'Component' does not have any construct or call signatures

In Question, when calling the Components above, it gives this error (for Dropdown):

TS2322: 
Type '{ Component: ({ options, value, setValue, placeholder, children, Icon, allowEmpty }: DropDownProps) => Element;
 value: Option<string>; setValue: Dispatch<SetStateAction<Option<string>>>; options: Option<...>[]; Icon: ({ className, onClick, size }: IconProps) => Element;
 title: string; }' 
is not assignable to type 
'IntrinsicAttributes & QuestionProps<({ options, value, setValue, placeholder, children, Icon, allowEmpty }: DropDownProps) => Element>'.   
Property 'value' does not exist on type 'IntrinsicAttributes & QuestionProps<({ options, value, setValue, placeholder, children, Icon, allowEmpty }: DropDownProps) => Element>

Expected Behaviour

  1. Compile without any Typescript errors
  2. Get IntelliSense to work so if you type <Question Component={Dropdown} ...>it shows the available props for Dropdown.

I spent ages finding the correct Syntax, but I can't find anything. I need a solution for ArrowFunctions as our EsLint forces us to use ArrowFunctions if possible.

If there is really no way to implement this, we might have to use classes again (Not prefered)

EDIT:

Yes!, I know I could just pass it as a child, but this is not the desired outcome, since this solution above is fine in JavaScript, but the TypeScript Syntax is the thing I don't know.

1 Answers

Following up on my comment: You can save yourself a lot of complexity by simply passing the rendered component instances as children to your Question component (see example below).

If it's truly critical to restrict the type of children to an enumeration of rendered components, then you can do something like: use a nominal (branded) type derived from ReactNode for both the type of children in Question's props, and as the return type of those components in the enumeration.

TS Playground

import {
  default as React,
  useState,
  type Dispatch,
  type SetStateAction,
  type ReactElement,
  type ReactNode,
} from 'react';

export type QuestionProps = {
  children: ReactNode;
  description?: string;
  title: string;
};

export const Question = ({ children, description, title }: QuestionProps): ReactElement => {
  return (
    <div className={'question-wrapper'}>
      <div className={'question-title'}>{title}</div>
      <div className={'question-description'}>{description}</div>
      <div className={'question-component'}>{children}</div>
    </div>
  );
}

// You can use this util if you are forwarding the return type of `useState` to components
export type PropsWithForwardedState<
  State,
  Props extends Record<string, unknown>,
> = Omit<Props, 'value' | 'setValue'> & {
  value: State;
  setValue: Dispatch<SetStateAction<State>>;
};

// Your Option type (You didn't show this)
type Option <Value extends number | string> = {
  id: string;
  value: Value;
};

// Your Radio component (You didn't show this)
declare const Radio: (props: PropsWithForwardedState<Option<string>, {
  options: unknown; // You didn't show this
  maxColumns: number; // You didn't show this
}>) => ReactElement;

const Example = (): ReactElement => {
  const [optionValue, setOptionValue] = useState<Option<string>>({ value:'', id:'' });
  const [options] = useState<unknown>(); // (You didn't show this)

  return (
    <>
      <Question title="Radio:">
        <Radio
          value={optionValue}
          setValue={setOptionValue}
          options={options}
          maxColumns={3}
        />
      </Question>
      {/*

      <Question title="Checkbox:">
        <Checkbox
          value={arrValues}
          setValue={setArrValues}
          options={options}
          maxColumns={3}
        />
      </Question>

      */}
      {
        // Same for Dropdown, Rating, Text, etc.
      }
    </>
  );
};

Related