6

Core of my question

const FinalComponent<GenericType extends 'a' | 'b'> = is invalid tsx syntax.

// The 1st line here is invalid tsx syntax
const FinalComponent<InvalidGenericType extends 'a' | 'b'> = 
    forwardRef<HTMLParagraphElement, PropsWithStandardRef<InvalidGenericType>>(({ value }, ref) => {
        return <Component forwardedRef={ref} value={value} />
    }) as ComponentType<InvalidGenericType>

Intended usage of the component:

const ExampleUsage = () => <FinalComponent<'b'> value="b" />

How do I make a generic type in this situation?


Additional context

For additional context, here is the rest of the code:

import { Ref, forwardRef } from 'react'

// These are the base props for the component. 
// In terms of usage, these are the props that I care about.
interface Props<GenericType extends 'a' | 'b'> {
    value: GenericType
}

// Adding forwardedRef to the props to define what props are usable inside the component
interface PropsWithForwardRef<GenericType extends 'a' | 'b'> extends Props<GenericType> {
    forwardedRef: Ref<HTMLParagraphElement | null>
}

// Adding standard ref to the props to define what props the component can accept from outside
interface PropsWithStandardRef<GenericType extends 'a' | 'b'> extends Props<GenericType> {
    ref?: Ref<HTMLParagraphElement | null>
}

// forwardRef is interfering with the inheritance of the generic types.
// This is a stand in for the expected return type of the component.
type ComponentType<GenericType extends 'a' | 'b'> = (props: PropsWithStandardRef<GenericType>) => JSX.Element

// The core component code
function CoreComponent<GenericType extends 'a' | 'b'> ({ value, forwardedRef }:PropsWithForwardRef<GenericType>):JSX.Element {
    return <p ref={forwardedRef}>{value}</p>
}

// !!!!!!!!!!! IMPORTANT BIT !!!!!!!!!!!!
// This is where my problem is, I need to be able to pass a dynamic generic type into PropsWithStandardRef and ComponentType.
// I'm not sure how to do that though because `const FinalComponent<InvalidGenericType extends 'a' | 'b'> = forwardRef()` is invalid
const FinalComponent<InvalidGenericType extends 'a' | 'b'> = forwardRef<HTMLParagraphElement, PropsWithStandardRef<InvalidGenericType>>(({ value }, ref) => {

    return <CoreComponent forwardedRef={ref} value={value} />

    // I need the `as ComponentType<InvalidGenericType>` bit because the inferred type that comes out of forwardRef
    // is making TS lose the generic types information
}) as ComponentType<InvalidGenericType>


// This is the end goal of how I want to be able to use this component
// I want to be able to pass a generic type into the component without TS complaining
const ExampleUsage = () => <FinalComponent<'b'> value="b" />

PS. I recognize that this example is a bit contrived, it is for the sake of simplifying my real world problem which features a far more complex component.


Similar but different question

This is different to React Typescript - dynamic types

In that question, it doesn't require passing the type information into the variable, more just changing what the type is based on what values the user provides.

I need the end use of the component to be able to pass a type into it.

3 Answers 3

7
+500

Your provided code is very close to working, but there are a just a few things which need to change for type correctness:

  1. Refs which hold HTML elements should not be mutable/nullable, because they are set and managed by React, and the Ref<T> util already includes null anyway. (This is unless you're doing something truly exotic like imperatively manipulating elements outside the render tree... but I've never even seen that in a codebase.) Because of this, I removed null from your union in Ref<HTMLParagraphElement | null>. (This was also causing a problem with passing the value to the actual paragraph element in CoreComponent.)

  2. The return type for your ComponentType needs to include null for the return type of forwardRef to be assignable to it. Speaking of return types for functions which return React elements, JSX.Element is simply an alias to ReactElement with any passed in the type params. I changed the JSX.Element references to ReactElement.

  3. A type annotation can still be applied to a variable that holds a function expression value. It is written the same way any other annotation is written: following the identifier name, like this:

    const add: (...numbers: number[]) => number = (...nums) => nums.reduce((sum, n) => sum + n, 0);
    

    The syntax above is not easy to read in my opinion, so I prefer wrapping the type in parentheses for readability. You can even utilize generics with the signature (needed in your case), and overloading is possible as well. See the FinalComponent in your code below, modified:

TS Playground link

import {
  default as React,
  createRef,
  forwardRef,
  ReactElement,
  Ref,
} from 'react';

type AorB = 'a' | 'b';

type Props<T extends AorB> = { value: T };
type PropsWithForwardRef<T extends AorB> = Props<T> & { forwardedRef: Ref<HTMLParagraphElement> };
type PropsWithStandardRef<T extends AorB> = Props<T> & { ref?: Ref<HTMLParagraphElement> };

function CoreComponent<T extends AorB> ({ value, forwardedRef }:PropsWithForwardRef<T>): ReactElement {
  return <p ref={forwardedRef}>{value}</p>;
}

const FinalComponent: (<T extends AorB>(props: PropsWithStandardRef<T>) => ReactElement | null) =
  forwardRef<HTMLParagraphElement, Props<AorB>>(({ value }, ref) => <CoreComponent forwardedRef={ref} value={value} />);

/**
 * The annotation for the function expression above can also be written this way,
 * which allows for overloading with multiple signatures, one on each line inside the braces:
 */
// const FinalComponent: {
//   <T extends AorB>(props: PropsWithStandardRef<T>): ReactElement | null;
// } = forwardRef<HTMLParagraphElement, Props<AorB>>(({ value }, ref) => <CoreComponent forwardedRef={ref} value={value} />);

/* Use: */

const ref = createRef<HTMLParagraphElement>();

const ExampleA = () => <FinalComponent<'a'> value="a" ref={ref} />;
const ExampleB = () => <FinalComponent<'b'> value="b" ref={ref} />;
const RefOptional = () => <FinalComponent<'a'> value="a" />;
const NoRestrictionA = () => <FinalComponent value="a" ref={ref} />;
const NoRestrictionB = () => <FinalComponent value="b" />;

const InvalidA = () => <FinalComponent<'a'> value="b" ref={ref} />;
const InvalidNotAorB = () => <FinalComponent value="c" />;
const InvalidNoValue = () => <FinalComponent<'a'> />;
Sign up to request clarification or add additional context in comments.

5 Comments

You mentioned wanting a "canonical" answer. I think the canon is the type declaration files for React (which are always evolving). If you need me to link to some places in the type declarations, I can search and add line numbers, but it seems like you are already familiar with searching the React types because of your understanding of required parameters for hooks, components, etc.
For solving my problem, being the most informative, and teaching me stuff about TS that I didn't even ask for in the question, I think you deserve the bounty :) To improve your answer further, it would be good if you could add a "short answer" section to the top of your post with the FinalComponent code, then provide "long answer" section with your existing answer below it. That makes your answer less intimidating for people looking for a quick solution.
I guess the bounty message can't be read any longer, but IIRC it said this was the last blocker for your team at work to adopt TypeScript. I'm glad it can now! Congrats on a nicer upcoming programming experience while at work.
Yes, this was preventing me from converting our common component design system to TS. Until the common components can be converted no other project can be converted to TS. It spent 3 days unanswered though so that's why I was desperate enough to post a 500 rep bounty.
I went with your commented out syntax. I think it is easier to read.
1

Variable can't use generic. define it as FC:

const FinalComponent: <InvalidGenericType extends 'a' | 'b'>(
  props: PropsWithStandardRef<InvalidGenericType>
) => JSX.Element = forwardRef<
  HTMLParagraphElement,
  PropsWithStandardRef<'a' | 'b'>
>(({ value }, ref) => {
  return <CoreComponent forwardedRef={ref} value={value} />
}) as ComponentType<'a' | 'b'>

Or use interface to define it:

interface FinalComponentType
  extends React.ForwardRefExoticComponent<PropsWithStandardRef<'a' | 'b'>> {
  <GenericType extends 'a' | 'b'>(
    props: PropsWithStandardRef<GenericType>
  ): JSX.Element
}

const FinalComponent = forwardRef<
  HTMLParagraphElement,
  PropsWithStandardRef<'a' | 'b'>
>(({ value }, ref) => {
  return <CoreComponent forwardedRef={ref} value={value} />
}) as FinalComponentType

Works fine:

const ExampleUsage = () => <FinalComponent<'a'> value="b" /> // There will be error

Comments

0

I've encountered this problem a number of times and while this is not a canonical answer this is how I usually resolve it:

You basically need your react function component to accept a generic type as the type parameter. The only way I found to deal with this was to create the function component without explicitly declaring it as a function component. The problem is:

const FinalComponent : React.FC<Props<GenericType<...>> = // There's no type variable that you can use at that point

To bypass this what I do is:

const FinalComponent = <T extends 'a'|'b'>(props: Props<T>): ReturnType<React.FC<Props<T>>> => null; //Return whatever you need here

This will declare a generic react function component that you can use like below:

const Res = () => <FinalComponent<'a'> value='a' />; // Works
const Res2 = () => <FinalComponent<'a'> value='b' />; // Errors 

Random link

3 Comments

Using React.FC in your answer answer disqualified you from the bounty. React.FC prevents us from declaring if children are allowed/required or not.
I used FC for the return type here. However as far as I know there's no way in typescript to prevent children from being passed. There's no way to trigger an error on <FinalComponent<'a'>><Child /></FinalComponent>
If you use children: NonNullable<ReactNode> in the props type interface then TS will throw this error if the component does not have children: "Property 'children' is missing in type '{}' but required in type 'Props'". You don't get this level of type specificity if you use React.FC though.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.