Creating generic components in typescript

rehkmansa
5 min readMar 1, 2024

--

Often times we need to build closely related components that accept the same, closely related or different types of props, it would always be a hassle trying to type this kind of components. An example of such scenario, is an input component that renders either a Select, Input, TextArea or Radio input component. I recently wrote an article on form validation with zod, you cant check it out here.

What are generics ?

Generics often come into play when working with functions, they are lose type definitions that allow “any” value that meets the constraint to be passed into it. Writing a simple generic function, we have

const greetStringUsers = (name: string)=>{
console.log(name)
} // only arguments of type string can be passed in

const greetAllUsers = <Type>(name: Type>){
console.log(name)
} // arguments of any type can be passed in

The example above shows how to create a generic function greetsUsers, the first can only be passed an argument of a string, while the other can receive all types of arguments, objects, functions, numbers, strings, arrays etc.

A good thing to understand about generics is that similar to unknown, before any operation can be performed suitable constraints should be applied to the generic type

const reverseArr = <Arr>(arr: Arr) => {
// would throw an error
return arr.reverse(); // Property 'reverse' does not exist on type 'Arr'.ts(2339)
};
/**
* we have this error because, we can pass strings, numbers,
* or objects into our function resulting in errors if we
* try to access the reverse property if it doesn't exist
*/

const reversArrWithConstraints = <Arr extends []>(arr: Arr)=>{
// specifying a constraint for our tye allows us to pass only arrays
// and use the right property on it
return arr.reverse()
}

A good thing to know about generics is that they recive their types from the arguments they are assigned, this type can also be explicitly passed into the function.

const simpleFunction = <Type>(arr: Type[]) => {
return arr
};

const array1 = simpleFunction([0, 1, 2, 3]);
// infers the type arguement as number

const array2 = simpleFunction<string>([0, 1, 2, 3]);
// has the type arguement passed as string, hence expects string instead
//throws error of: Type 'number' is not assignable to type 'string'.ts(2322)

Defining generic components?

Let’s look at creating a very simple generic component, below.

// defining props using interface
interface ISimpleComponentProps<Type> {
text: Type;
}

// defining props using type
type TSimpleComponentProps<Type> = {
text: Type;
};

// geneic component can use a <,> or with an extend constraint so it doesnt error
const SimpleComponent = <Type,>(props: TSimpleComponentProps<Type>) => {
return <></>;
};

const SimpleComponent2 = <Type extends []>(props: TSimpleComponentProps<Type>) => {
return <></>;
};

To create a generic component you define the props to accept a generic argument and then create same type definition in when creating your component. The code block shows how to create generic props with types or interfaces.

To build a more complex generic component like our example earlier would require a lot more work. We need to create our component api, I consider this step the most important part as it revolves around, how our component would be used by us or external developers. Its important to ask questions like, what kind of props would our input accept, what layers would we like to abstract in this example. For our component, we are creating a multi input with generically typed props that has a super prop called variant that is a union type of all our possible types.

type InputVariants = "normal" | "floating" | "radio" | "text-area";

We proceede to create the different variants for our Input component and their prop definitions.

interface NormalInputProps {
text: string;
}

// I am returning an empty fragment to demonstrate the typing.
const NormalInput = (props: NormalInputProps): JSX.Element => <></>;

interface FloatingProps {
label: string;
}

const FloatingInput = (props: FloatingProps): JSX.Element => <></>;

interface RadioProps {
options: string[];
}
const RadioInput = (props: RadioProps): JSX.Element => <></>;

interface TextAreaProps {
rows: string | number;
columns: string | number;
}
const TextArea = (props: TextAreaProps): JSX.Element => <></>;

This part is a bit complex, you need to create a discriminated union prop that accepts your variant as a generic and then returns the individual prop.

// Create a picker props to choose the selected input.
type PickProps<Selected extends InputVariants> = Selected extends "normal"
? NormalInputProps
: Selected extends "floating"
? FloatingProps
: Selected extends "radio"
? RadioProps
: Selected extends "text-area"
? TextAreaProps
: never;

PickProps accepts an Input variant, that uses the extends keyword to narrow the type down to each element props. To create the final prop definition for our component, we have a union type of PickProps and our variant.

type AllInputProps<Variant extends InputVariants> = PickProps<Variant> & {
variant?: Variant;
};


// passing an a default value is essential to scope the props to the default
export const InputExample = <Variant extends InputVariants = "normal">(
props: AllInputProps<Variant>,
) => {
const { variant = "normal", ...rest } = props;

const renderInput = (): React.ReactNode => {
// not doing a strong prop narrowing to save myself from the typescript headache
// this is bad because the component can be initialized with props assigned to Floating input
// but it defaults to a normal input
switch (variant) {
case "normal":
return <NormalInput {...(rest as unknown as NormalInputProps)} />;
case "floating":
return <FloatingInput {...(rest as unknown as FloatingProps)} />;
case "radio":
return <RadioInput {...(rest as unknown as RadioProps)} />;
case "text-area":
return <TextArea {...(rest as unknown as TextAreaProps)} />;
}
};

return renderInput();
};

<InputExample variant="floating" />;
<InputExample variant="radio" />;
<InputExample variant="text-area" />;
<InputExample />; // defaults to normal
<InputExample<"radio"> />; // forcing generic type

This component creates a strongly typed InputElement that accepts props based on the variant, it builds on the concept of Polymorphic components in React. Personally I won’t recommend this approach when creating components, but certain situations require hacky solutions like this. This is a brief guide on how to create your own component, you can build on it and create better components. You can find a link to the repo here.

Incase you missed it earlier, here is a link to my article on form validation.

--

--

rehkmansa

Just another human, i hack frontend(React) in my free time, currently transitioning into software engineering