I've been tackling an issue of abstracting out some logic for component creation in order to reduce a lot of duplication. As part of this, I have a generic Builder component which I use to dynamically render components baased on the props provided.
The issue comes from the fact that I defined elements as similar to the following:
type InputMap = typeof INPUTS
const INPUTS = {
text: {
component: TextInput,
controlled: false
},
select: {
component: Select
controlled: true
}
}
// Props for TextInput component
type TextProps = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void
onBlur: (e: ChangeEvent<HTMLInputElement>) => void
}
// Props for Select component
type ElementProps = {
onChange: (value: string) => void
onBlur: () => void
}
I want to pass on my fields in a format similar to this:
const fields = [
{
input: "text",
props: {
onChange: e => console.log(e.target.value)
}
},
{
input: "select",
props: {
onChange: value => console.log(value)
}
}
]
This is the type I came up with:
import { ComponentProps } from "react";
export type FieldConfig<T extends FieldValues, K extends keyof InputMap> = {
input: K;
props?: ComponentProps<InputMap[K]["Component"]>
};
However in my Builder component, there's an issue when rendering the component.
<div>
{ fields.map(({ input, props }) => {
const { Component, controlled } = INPUTS[input]
return <Component {...props} /> // ERROR HERE
})}
</div>
const { input, props } = field
TypeScript at that point gives me the following error:
Types of property 'onBlur' are incompatible.
Type 'ChangeHandler' is not assignable to type '() => void'
Is there any way for me to narrow the types from a union to a specific instance of that union in this case? I'm trying my best to avoid any type assertions here. Any help would be greatly appreciated!
You can use a common field interface and a union type to define how your form structure should be handled like this.
interface FieldDefinition<TType extends string, TElement extends HTMLElement> {
input: TType
placeholder?: string
onChange?: React.ChangeEventHandler<TElement>
onBlur?: React.ChangeEventHandler<TElement>
}
interface TextField extends FieldDefinition<'text', HTMLInputElement> {
}
interface SelectField extends FieldDefinition<'select', HTMLSelectElement> {
options: Record<PropertyKey, any>
}
type FormField = TextField | SelectField
const formFields: FormField[] = [
{
input: 'text',
onChange: (event) => console.log(event.target.value)
},
{
input: 'select',
onChange: (event) => console.log(event.target.value),
options: {
foo: 'Foo',
bar: 'Bar',
baz: 'Baz'
}
}
]
This allows it to be properly used when returning the JSX, here's a link to a TypeScript playground showing it used as a component.
This has the added bonus of allowing you to define specific type specific properties that can be defined like an options object for the select input.