Using generics to create extensible components using Typescript

Viral Tagdiwala
4 min readMar 27, 2022

--

We often run across this wormhole of creating elements that match our codebase/designs by over-riding the ones provided out-of-the-box via the front-end library or, in general, try and create custom elements for our codebase.

The issue comes when the code-base expands and now you want your component to do more than what you anticipated at the start, leaving you with no other option but to either re-write the old component and test everything that uses it OR write a new component from the ground up for your specific use case. The latter might seem enticing at first, but you easily end up with this mess of a codebase where you have multiple varieties of the same component, making readability for a new developer on the team to tank.

I’ll be starting off with how you can use generics in typescript to your advantage to create an extensible select element!

My assumption here is that we’ll be using Material UI to provide the base element, but you can follow along and use this on top of your current element too.

We start off by importing everything we’ll be needing

import { MenuItem, Select } from '@mui/material'
import React from 'react'
import { useFormContext, Controller, FieldErrors } from 'react-hook-form'

React hook form provides some nifty features for handling form submissions and managing dirty field states among many other things! Again, this is entirely optional, you can skip this if your codebase doesn't use react hook forms.

The key part — creating the interface for your form element

interface SelectElementProps<T> {
name: string
label: string
values: T[]
displayValue: keyof T
required?: boolean
fullWidth?: boolean
margin?: 'dense' | 'none'
errorObj?: FieldErrors
defaultValue?: string
}

What’s up with this “T”

T is typescripts Generics declaration. Think of this as something that is going to be a type declared at run-time instead of compile time.

Similarly in C#, if the type T represents is not a value type but a more complex type (class) or interface, it could be named/declared as T followed by the class name like TCar to help denote a valid type for future programmers.

Why not just use “ANY”

“Any” as the name implies, can mean anything — type safety gets thrown into the garbage and you don’t get the same safety net you’d hope to have when you use typescript in your codebase.

From one of my previous articles on software architecture patterns I talk about the Open-closed principle, in brief, it states “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”, that is, such an entity can allow its behavior to be extended without modifying its source code.

Using these generic types is a very good example of how to stick to the open-closed principle in your code.

Coming back to the component, let’s finish chalking it up

import { MenuItem, Select } from '@mui/material'
import React from 'react'
import { useFormContext, Controller, FieldErrors } from 'react-hook-form'
interface SelectElementProps<T> {
name: string
label: string
values: T[]
displayValue: keyof T
required?: boolean
fullWidth?: boolean
margin?: 'dense' | 'none'
errorObj?: FieldErrors
defaultValue?: string
}
export function SelectElement<T>({
name,
values,
displayValue,
required,
fullWidth,
label,
margin,
errorObj,
defaultValue
}: SelectElement<T>): JSX.Element {
const { control } = useFormContext()
function getProperty<T, Key extends keyof T>(obj: T, key: Key): string {
return obj[key] as string
}
return (
<React.Fragment>
<Controller
render={({ field: { onChange, onBlur, ref } }) => (
<React.Fragment>
<Select
label={label}
onChange={onChange}
onBlur={onBlur}
ref={ref}
error={isError}
fullWidth={fullWidth}
margin={margin}>
{values.map((e: T) => (
{getProperty(e, displayValue)}
))}
</Select>
</React.Fragment>
)}
name={name}
control={control}
defaultValue={defaultValue}
rules={{ required: required }}
/>
</React.Fragment>
)
}
export default SelectElement

If we take a look at the map function here,

{values.map((e: T) => (
{getProperty(e, displayValue)}
))}
function getProperty<T, Key extends keyof T>(obj: T, key: Key): string {
return obj[key] as string
}

You see how we can specify different keys whenever we use this component at different places, maybe a record at one place wishes to use “name” as the key whereas at some other places they want to use “value” as the key

This helps you reduce the amount of pre-processing you need to do on the input array and just bank on the fact that typescript will handle things and display the right key you specified for your array.

Imagine, using “any” here, you’d not get typescript to scream at you if you provided a key that doesn't exist in the type of array you specified and you’d end up getting just a bunch of console errors!

And that’s it! Hope this helps you in the thought process of creating generic extensible forms in the future!

--

--

Viral Tagdiwala
Viral Tagdiwala

No responses yet