In a codebase I am working on we are extracting components which should work in a generic way (because we want to put them into a separate npm package eventually).
Running Example
For example, we have the following Form component in React which uses any at two places. It should somehow behave in a generic way here (or derive the types from the given props):
// THIS SHOULD BE LIKE A FORM THAT WE CANNOT TOUCH
// BECAUSE IT COMES FROM A LIBRARY
// SO HOW TO TYPE IT PROPERLY?
import * as React from "react";
type FORM_ELEMENT_TYPE = "textfield" | "numberfield";
export type FormElement = {
key: string;
label: string;
type: FORM_ELEMENT_TYPE;
};
type FormProps = {
formData?: any; // <- HOW TO TYPE IF formData CAN BE ANYTHING? PERHAPS WITH THE HELP OF formElements?
formElements?: FormElement[];
onChange: (form: any) => void; // <- HOW TO TYPE IF WE DO NOT KNOW THE FORM? WE KNOW THE formElements TO DERIVE THE OUTPUT FROM, NO?
};
const Form: React.FC<FormProps> = ({
formData = {},
formElements = [],
onChange,
}) => {
return (
<form>
{formElements.map((formElement) => {
if (formElement.type === "textfield") {
return (
<label key={formElement.key}>
{formElement.label}:
<input
type="text"
value={formData[formElement.key]}
onChange={(event) =>
onChange({
...formData,
[formElement.key]: event.target.value,
})
}
/>
</label>
);
}
if (formElement.type === "numberfield") {
return (
<label key={formElement.key}>
{formElement.label}:
<input
type="number"
value={formData[formElement.key]}
onChange={(event) =>
onChange({
...formData,
[formElement.key]: event.target.valueAsNumber,
})
}
/>
</label>
);
}
return null;
})}
</form>
);
};
export default Form;
The App component -- where we have control, because it's not a component in the library -- uses the Form component. Here we have the issue of not knowing about the type in the onChange event handler which our Form component uses:
import * as React from "react";
import Form, { FormElement } from "./Form";
const App = () => {
const [formData, setFormData] = React.useState({
name: "David",
age: 20,
});
const formElements: FormElement[] = [
{
key: "name",
label: "Name",
type: "textfield",
},
{
key: "age",
label: "Age",
type: "numberfield",
},
];
// IS THERE ANY WAY TO INFER THIS? v
const handleChange = (newFormData: any) => {
setFormData(newFormData);
};
return (
<Form
formData={formData}
formElements={formElements}
onChange={handleChange}
/>
);
};
export default App;
How can I type the Form and App components with generics or/and inference to always know about the correct types for all 3 places where any is used at the moment? For the sake of completeness, here a link to the complete code as a running example.
I'd like to hear readers' opinions on Part 2 where the Form component gets a new prop called customFormElements.
It is a bit tricky. First I'll not concern myself with the implementation of Form and not care about type errors inside and just try to find a way to type Form so that when you use it from outside, the typings are correct.
There are two main ways to go when putting a generic here: you could either try to infer the value for formData given the value of formElements, or you could use the descriptions for fields from formElements and use them to infer the type of formData. The latter option probably makes more sense, so let's start with this one:
// I'll need this type later so I move it to enum
enum FormElementType {
// I believe adding "field" suffix here is redundant, but you may return
// it if you'd like
text = 'text',
number = 'number',
}
// What values must be passed for field of each type
type FieldTypeMap = {
[FormElementType.text]: string,
[FormElementType.number]: number,
}
export type FormElement = {
key: string;
label: string;
type: FormElementType;
};
// See explanation below
type FormProps<TElement extends FormElement> = {
data: FormData<NoInfer<TElement>>;
onChange: (form: FormData<NoInfer<TElement>>) => void;
elements: readonly TElement[];
};
// Utility types
type NoInfer<T> = T extends any ? T : T;
type FormData<TElement extends FormElement> = {
[Key in TElement["key"]]:
TElement extends infer U extends FormElement & {key: Key}
? FieldTypeMap[U['type']]
: never
}
export declare const Form: <TElement extends FormElement>({
data,
elements,
onChange,
}: FormProps<TElement>) => JSX.Element;
So the generic argument acccepts a union of objects that describe form fields, like in your example
TElement = {
key: "name",
label: String,
type: FormElementType.text,
} | {
key: "age",
label: "Age",
type: FormElementType.number,
}
Then we use FormData type to extract the shape for formData object from this union. TElement extends infer U extends FormElement & {key: Key} will map over values of TElement union, find the one that has its key property equal to Key and assign it to U. Then we extract the value type for this field using U['type'] field and a predefined mapping between value types and FormElementType values. Note: you need at least TS 4.8 for infer ... extends ... syntax to work.
The NoInfer thing is a trick that basically tells typescript that it must not infer the value for TElement generic argument from the places where it is wrapped into NoInfer. So when using Form typescript will only be allowed to infer TElement from the value of the formElements field, and not from formData.
Also I made formData and onChange required arguments, because I'm not sure what it means for them to be optional.
This approach works, but it's a bit awkward to use, because by default when defining an object with string values TS doesn't infer them as string literals but just as string values. Therefore you will have to add as const to formElements
const formElements = [
{
key: "name", // because of "as const" this is inferred as `"name"` and not as just `string`
label: "Name",
type: FormElementType.string,
},
{
key: "age", // Similarly this is inferred to be `"age"` and not just `string`
label: "Age",
type: FormElementType.number,
}
] as const
<Form
elements={formElements}
// ...
/>
When TS 5 is out, this might become less awkward, because you will be able to write TElement extends const FormElement or something.
Going the other way is also possible
type FormProps<TData extends {}> = {
data: TData;
onChange: (form: TData) => void;
elements: FormElements<TData>;
};
type ElementType<T> =
// You neeed to manually check for all possible `T` types here and infer what the values for `type` in `formElements` might be
T extends string ? FormElementType.text :
T extends number ? FormElementType.number : never
type FormElements<TData extends {}> = ReadonlyArray<{
[key in keyof TData]: {
key: key,
label: string,
type: ElementType<TData[key]>
}
}[keyof TData]>
export declare const Form: <TData extends {} extends {}>({
data,
elements,
onChange,
}: FormProps<TData>) => JSX.Element;
This way you don't need to use as const but only when passing formElements to Form inline:
<Form
elements={[
{
key: "name",
label: "Name",
type: FormElementType.string,
},
{
key: "age",
label: "Age",
type: FormElementType.number,
}
]}
// ...
/>
And this is also very awkward, because if you use wrong types you will get an error on formElements and not on formData field which is confusing and weird.
One way how to get rid of having to use this as const I can think of is the following: if you can change your API so that you use an object of formElements and not an array, then we can make typescript infer key names from this object keys. Moreover if instead of FormElementType being an enum, you make it an object with literal properties, you can use its values to better infer the value of the type field. Something like this:
export type FormElementType =
(typeof FormElementType)[keyof typeof FormElementType];
export const FormElementType = {
text: 'text',
number: 'number',
} as const;
type FieldTypeMap = {
[FormElementType.text]: string;
[FormElementType.number]: number;
};
export type FormElement = {
label: string;
type: FormElementType;
};
// See explanation below
type FormProps<TElements extends Record<string, FormElement>> = {
data: FormData<NoInfer<TElements>>;
onChange: (form: FormData<NoInfer<TElements>>) => void;
elements: TElements;
};
// Utility types
type NoInfer<T> = T extends any ? T : T;
type FormData<TElements extends Record<string, FormElement>> = {
[Key in keyof TElements]: FieldTypeMap[TElements[Key]["type"]];
};
export declare const Form: <TElements extends Record<string, FormElement>>({
data,
elements,
onChange,
}: FormProps<TElements>) => JSX.Element;
And use it like this:
const formElements = {
name: {
label: "Name",
type: FormElementType.text,
},
age: {
label: "Age",
type: FormElementType.number,
},
}
<Form
data={formData}
elements={formElements}
onChange={handleChange}
/>
Now speaking about the implementation of Form, I don't think you can get away and not see any errors inside Form if you use such complicated types, so honestly good luck typing it. I think it's mostly just easier to use type casts, or to not type it very meticulously at all, and just move these complex typings to a separate declaration file, where they will be visible from outside, and not interfere with implementation in your npm package.
I didn't write a full working implementation of this form, but here were some ideas you may find useful to type this mess. Forms are hard
I have two different components that have one prop in common in their states, in this minimum example it will be the pictures prop which is an array of strings:
Apartment.ts
type ApartmentType = {
landlord: string;
pictures: string[];
}
function Apartment () {
const [ apartmentData, setApartmentData ] = useState<ApartmentType>({
landlord: 'juan',
pictures: [ 'url1', 'url2' ]
})
return (
<h1>Apartment</h1>
<FileUploader setFunction={setApartmentData} />
)
}
House.ts
type HouseType = {
owner: string;
pictures: string[];
}
function House () {
const [ houseData, setHouseData ] = useState<HouseType>({
owner: 'Jhon',
pictures: [ 'url1', 'url2' ]
})
return (
<h1>House</h1>
<FileUploader setFunction={setHouseData} />
)
}
As you can see I'm adding a FileUploader component which will update the pictures array of its parent using the set function that comes with the useState hook:
type FileUploaderProps = {
setFunction: React.Dispatch<React.SetStateAction<HouseType> | React.SetStateAction<ApartmentType>>
}
function FileUploader ({ setFunction }: FileUploaderProps) {
function updatePictures () {
setFunction((prevValue: any) => ({ ...prevValue, pictures: [ ...prevValue.pictures, 'newUrl1', 'newUrl2'] }))
}
return (
<div>
<h1>File Uploader</h1>
<button type='button' onClick={updatePictures}></button>
</div>
)
}
But here is where the issue appears, in Apartment and House for the setFunction prop in FileUploader TS is giving me this error:
Type 'Dispatch<SetStateAction<HouseType>>' is not assignable to type 'Dispatch<SetStateAction<ApartmentType> | SetStateAction<HouseType>>'.
Type 'SetStateAction<ApartmentType> | SetStateAction<HouseType>' is not assignable to type 'SetStateAction<HouseType>'.
Type 'ApartmentType' is not assignable to type 'SetStateAction<HouseType>'.
Property 'owner' is missing in type 'ApartmentType' but required in type 'HouseType'
What I'm doing wrong?, I though that typing the setFunction prop in FileUploader as:
React.Dispatch<React.SetStateAction<HouseType> | React.SetStateAction<ApartmentType>>
would be sufficient to account for the two possibilities but it is not.
Here is a link for a playground
You are making some confusion with the template types and Union type in "wrong" place (React.SetStateAction<Apartment> and React.SetStateAction<House>). It would be best if you made the function a Union type such as:
type FileUploaderProps = {
setFunction: React.Dispatch<React.SetStateAction<House>> | React.Dispatch<React.SetStateAction<Apartment>>
}
I have type error.
Exists child component Pagination with type of props:
interface BaseProps {
url: string;
rowKey: string;
renderItem: (item: unknown) => React.ReactNode;
params?: Record<string, unknown>;
foundTextSuffix?: string;
separated?: boolean;
useLaggy?: boolean;
}
In parent component function renderItem is passed to child component Pagination. This function needed to render other nested components in different places.
<Pagination
url={API.service('search')}
rowKey="id"
foundTextSuffix=" services"
renderItem={({id, shortTitle, organizationTitle}: ServiceExtended) => (
<ListItem
title={<Link href={{pathname: '/services/[serviceId]', query: {serviceId: id}}}>{shortTitle}</Link>}
subTitle={organizationTitle}
/>
)}
params={frozen}
/>
Argument of function renderItem has type unknown in type of Pagination component, strict mode is true. When I try to pass a function in props of Pagination component with argument type ServiceExtended, for example, there is type error:
Type 'unknown' is not assignable to type 'ServiceExtended'
I can't list all possible types in argument of renderItem, because there are a lot of them, there will be new ones in the future and this approach doesn't work with strict mode.
Please, help to find solution
I wrote simple example of my case below. This type error is occurring only with strict mode
type childPropType ={
render: (data: unknown) => JSX.Element
}
type parentPropType = {
id: number,
greeting: string
}
const data: parentPropType = {
id: 1,
greeting: 'Hello'
}
const Child = (props: childPropType) => {
const {render} = props;
return (
<div>{render(data)}</div>
)
}
const Parent = () => {
return (
<div>
<Child
render={
({id, greeting}: parentPropType) => <div>{greeting}</div>
}
/>
</div>
)
}
I have a HoC in React that simply passes an extra property called name:
export interface WithNameProps {
name: string;
}
function withName<P extends {}>(Wrapped: React.ComponentType<P & WithNameProps>): React.FC<P> {
const WithName: React.FC<P> = (props) => <Wrapped {...props} name="typescript" />
return WithName;
}
However, when I use it with a component:
const Person: React.FC<{age: number, name: string}> = ({name, age}) => <p>Name: {name}. Age: {age}</p>
const Me = withName(Person);
<Me age={10} />
I get the following error:
Property 'name' is missing in type '{ age: number; }' but required in type '{ age: number; name: string; }'
Typescript is not able to infer that P here is {age: number, name: string} without WithNameProps i.e. {age: number}.
It works if I explicitly pass the type as in here:
const Me = withName<{age: number}>(Person);
It also works if the I declare the props of Person as an intersection type:
const Person: React.FC<{age: number} & {name: string}> = ({name, age}) => <p>Name: {name}. Age: {age}</p>
Here, Typescript is probably able to destructure the type and infer it.
Can I modify this in a way such that I don't have to explicitly state the type or pass the intersection type?
I have Typescript playground link here (along with a simpler example): Click here
My React component has a prop called propWhichIsArray which will be an array of objects. These objects will have an id which is an ID and text which is a string. How can you type this with TypeScript?
type Props = {
propWhichIsArray: {
id,
text: string;
}[]
};
const Component: React.FC<[Props]> = ({ propWhichIsArray }) => {
//
Im getting an error:
Property 'propWhichIsArray' does not exist on type '[Props] & { children?:
ReactNode; }'. TS2339
The main issue is that you're doing React.FC<[Props]> instead of React.FC<Props>. With the square brackets, you're creating a tuple type, whose's zeroth element is of type Props, and then you're having that tuple be the props of your component.
interface Props {
propWhichIsArray: {
id: ID; // I assume ID is defined elsewhere
text: string;
}[]
}
const Component: React.FC<Props> = ({ propWhichIsArray }) => {
If this data in the array is being used in other places, you may want to pull it out to its own interface:
interface Thingamajig {
id: ID;
text: string;
}
interface Props {
propWhichIsArray: Thingamajig[];
}