Fragmented Thought

Using Typescript generic type variables in ReactFunction components

By

Published

Lance Gliser

If you've found this entry, you likely already know why you'd want a Generic type variable. Good on you! Enjoy the yummy syntax. If you're new to the concept, I'd still like you to come at this topic from a code approach. Trust me, the actual syntax difference is minimal. Hopefully you find it a comfort to see how similar it is.

Creating a Generic React.ReactFunction component

Ignoring a few of my other syntax choices, I'm going to write this out with comments inline to help explain alternatives and why I choose the options I have.

GenericComponent.tsx

import React, { ReactElement } from "react"; // Setup a generic interface which will inform our consuming components. export interface GenericComponentProps<T extends unknown = unknown> { onSelect: (event: React.MouseEvent, selected: T) => void; options: { value: T; content: React.ReactNode }[]; } // The most simple version of the arrow syntax uses a "comma hack" // const GenericComponent = <T,>(props) => {} // This syntax works great, until you need to provide a default. // With this, your users will be forced to supply a generic type, // Using the extends syntax is roughly the same, but allows the assignment of a default. const GenericComponent = <T extends unknown = unknown>({ onSelect, options, }: // // Most of us are comfortable with the standard syntax below: // const GenericComponent: React.Function: () => {} // Under the covers, it's really just: // (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null; // We can manually reproduce that here accepting the generic from the arrow function. GenericComponentProps<T>): ReactElement<any, any> | null => { return ( <div> {options.map(({ value, content }, index) => ( <button key={index} onClick={(event) => onSelect(event, value)}> {content} </button> ))} </div> ); }; export default GenericComponent;

Using a React.ReactFunction that accepts Generics

I've written some examples in Storybook in case you'd like to pull them down to play with.

GenericComponent.stories.tsx

First a bit of Storybook boilerplate for registration:

import GenericComponent, { GenericComponentProps } from "./GenericComponent"; import { withCardDecorators } from "../../../../../.storybook/decorators"; import { ComponentMeta, ComponentStory } from "@storybook/react"; import React, { useState } from "react"; export default { title: "Forms/Inputs/GenericComponent", component: GenericComponent, decorators: [...withCardDecorators], // I'm leaving the generic type off here on purpose. // The options are going to be embedded in the Template directly instead. // This way lets us show the effects of the Generic more easily. args: {} as GenericComponentProps, // For a real component that accepts generics you should expect to supply them // along with some meaninful default args to keep to enable Storybook controls. // args: { // { content: <p>One</p>, value: 1 }, // { content: <p>Two</p>, value: 2 }, // { content: <p>North</p>, value: 3 }, // ]} as GenericComponentProps<number>, } as ComponentMeta<typeof GenericComponent>;

Omitting the generic

const UnknownTemplate: ComponentStory<typeof GenericComponent> = ({ ...props }) => ( <GenericComponent {...props} options={[ // Success! { content: <p>One</p>, value: "one" }, // TS2322: Type 'number' is not assignable to type 'string'. { content: <p>Two</p>, value: 2 }, // Success! { content: <p>North</p>, value: "north" }, ]} // Our selected type is not supplied onSelect={(event, selected) => console.info(event, selected)} /> ); export const Unknown = UnknownTemplate.bind({});

Using a string union type

type CardinalDirectionType = "north" | "south" | "east" | "west"; const TypeGenericTemplate: ComponentStory<typeof GenericComponent> = ({ ...props }) => ( <GenericComponent<CardinalDirectionType> {...props} options={[ // TS2322: Type '"one"' is not assignable to type 'CardinalDirectionType'. { content: <p>One</p>, value: "one" }, // TS2322: Type 'number' is not assignable to type 'CardinalDirectionType'. { content: <p>Two</p>, value: 2 }, // Success! { content: <p>North</p>, value: "north" }, ]} // Our selected is of type CardinalDirectionType onSelect={(event, selected) => console.info(event, selected)} /> ); export const TypeGeneric = TypeGenericTemplate.bind({});

Using a enum

enum CardinalDirectionEnum { North = "north", // Yes, this is a very silly thing to do. But it revealed // an interesting behavior to desribe. See: value: 2. South = 0, East = "east", West = 1, } const EnumGenericTemplate: ComponentStory<typeof GenericComponent> = ({ ...props }) => ( <GenericComponent<CardinalDirectionEnum> {...props} options={[ // TS2322: Type '"one"' is not assignable to type 'CardinalDirectionEnum'. { content: <p>One</p>, value: "one" }, // Shockingly, this is valid. That's because Enum could be 2, even though I didn't use it. // If I don't provide mixed enum values and make them all strings, we get: // TS2322: Type 'number' is not assignable to type 'CardinalDirectionEnum'. // The lesson here is to provide actual enum values as strings when possible! { content: <p>Two</p>, value: 2 }, // TS2322: Type '"north"' is not assignable to type 'CardinalDirectionEnum'. // Not surprising. Just because the string is "north" doesn't mean it's CardinalDirectionEnum.North { content: <p>North</p>, value: "north" }, // Success! { content: <p>North</p>, value: CardinalDirectionEnum.North }, ]} // Our selected is of type CardinalDirectionType onSelect={(event, selected) => console.info(event, selected)} /> ); export const EnumGeneric = EnumGenericTemplate.bind({});

Why should we write components with Generics?

Now comes the bit I promised: reasons. Sorry, the free beer is still tomorrow.

Off the top, I think it's important to point out a design choice I made this example. I provided a default type to the generic: <T extends unknown = unknown>. That's a pretty major design decision and not one you should take lightly.

The default you provide would rarely be unknown as you could expect more structured types or interfaces. Use this option when you know the basic properties.

I would personally suggest that more often than not, you won't know best. That is why we are making it generic after all. My tendency is to the <T,> syntax. The big selling point to this method is the automatic inspection of the first argument consumers provide. Whatever they say it is, the rest has to match. If that's ever a problem, they can take the power by using our generic.

The biggest win comes from those that know ahead of time they'll want strong typing. Using a declared type like CardinalDirectionEnum makes defining options.value autocomplete. It's also the only way we get a type for the onSelected value.

Things that don't work so well

This is actually the second attempt at writing this entry. The first sent me off from the code defeated, and a bit enraged. Let's talk for just a moment about the basic input, select, textarea properties value and onChange. Here's the component I started off with:

import React, { Dispatch, ReactElement, SetStateAction } from "react"; export interface GenericComponentProps<T extends unknown = unknown> { value: T; setValue: Dispatch<SetStateAction<T>>; options: { value: T; content: React.ReactNode }[]; } // Using the extends syntax is roughly the same, but allows the assignment of a default const EnumSelect = <T extends unknown = unknown>({ value, setValue, options, }: GenericComponentProps<T>): ReactElement<any, any> => { return ( <select value={value} onChange={(event) => setValue(event.target.value)}> {options.map(({ value, content }, index) => ( <option key={index}>{content}</option> ))} </select> ); }; export default EnumSelect;

This thing. This maddening thing right here:

TS2322: Type 'T' is not assignable to type 'string | number | readonly string[] | undefined'.
  Type 'unknown' is not assignable to type 'string | number | readonly string[] | undefined'.
    Type 'unknown' is not assignable to type 'readonly string[]'.
      Type 'T' is not assignable to type 'readonly string[]'.
        Type 'unknown' is not assignable to type 'readonly string[]'.

I had hoped I could use my CardinalDirectionType as it's just strings at least. Even that won't pass muster. I never did manage to get around it. The internal types are a bit unforgiving of generics. If I managed to satisfy value, setValue would start showing errors. If anyone knows how to do this, I would dearly love to know. Please tweet or email me.