Enforced properties on a React component with TypeScript - reactjs

I would like to enforce properties on a React component with TypeScript, but I am getting weird behaviour. Bellow I am pasting only simple examples:
function FunctionalComponent(props: { color: string }) {
return <></>;
}
type ComponentWithName<I extends React.FunctionComponent<{ name: string } & React.ComponentProps<I>>> = I;
const component: ComponentWithName<typeof FunctionalComponent> = FunctionalComponent;
The code above will pass even I declared that the component must have a property name. With this code I need to get an errror, because the FunctionalComponent does not include a name property.
On the other hand, this works:
function FunctionalComponent(props: { color: string }) {
return <></>;
}
type ComponentWithName<I extends React.FunctionComponent<{ name: string }>> = I;
const component: ComponentWithName<typeof FunctionalComponent> = FunctionalComponent
This code will throw a TypeScript error, exactly what I need. But the issue is, that the FunctionalComponent can not have additional properties unless I add them manually to the React.FunctionComponent.
The goal is to enforce a component to have the "name" property, but allow to have more additional (not specified) properties.
I am using TypeScript version 4.4.4 and React version 17.0.2
Edit:
The true use case is this:
function Component<
I extends
| React.ComponentClass<
{
onChange: (event: React.ChangeEvent) => void;
} & React.ComponentProps<I>
>
| React.ComponentType<
{
onChange: (event: React.ChangeEvent) => void;
} & React.ComponentProps<I>
>
>(
props: {
component?: I;
} & Omit<React.ComponentProps<I>, "onChange">
) {
const { component: Component, ...rest } = props;
const handleChange = () => {
//
};
return (
<div>
{Component ? (
<Component
{...(rest as React.ComponentProps<I>)}
onChange={handleChange}
/>
) : (
<input onChange={handleChange} />
)}
</div>
);
}
class ComponentClass extends React.Component<{
color: "blue" | "yellow";
}> {
render() {
return (
<input style={{color: this.props.color}} />
);
}
}
function ComponentFunction(props: { color: "blue" | "yellow" }) {
return <input style={{color: props.color}} />;
}
function App() {
return (
<>
<Component component={ComponentClass} color="blue" />
<Component component={ComponentFunction} color="blue" />
</>
);
}
The <Component component={ComponentClass} color="blue" /> will throw an type error but the <Component component={ComponentFunction} color="blue" /> does not. I need to enforce passed components to have the onChange property with the specified type.

I may be missing something, but do you not just need to enforce the type of the Props rather than create a typed component?
interface NameProps {
name: string;
}
type NamedComponent<T extends NameProps> = (props: T) => JSX.Element;
const notANamedComponent: NamedComponent<{ int: number }> // ...this will give you an error
const aNamedComponent: NamedComponent<{ int: number; name: string}> //OK

The issue is that passing extra fields to the component is always valid, if you want to require it to do something with it is harder to type.
For instance this is valid code:
// from this context the argument will be called with an argument with both a and b properties
function takeF(f: (data: {a:string, b:number})=>any){}
// this function takes an object with an a property, but passing other properties would still be valid
function f(data: {a:string}){}
// this is allowed because passing an object with extra fields is still valid.
takeF(f)
The reason you are getting an error with {name: string} and {color:string} is because those have no overlap so typescript does give you errors, so the solution is to constrain your generic to what you actually need.
declare function Component<
ComponentProps extends { onChange: (event: React.ChangeEvent) => void; }
>(
props: {
component?: React.ComponentClass<ComponentProps> | React.ComponentType<ComponentProps>;
} & Omit<ComponentProps, "onChange">
): any
this way if the component doesn't have an onChange then there is no overlap and you get the error you are expecting and if there are extra properties it is fine because those are already being captured by the generic behaviour. Also note this is basically the same thing that #Marcus is saying, just constrain the generic to what you actually need.

Related

Type is not assignable to type LibraryManagedAttributes

I am trying to create array field component that will accept any React Functional component that has BaseProps. However I get an error when rendering Component in ArrayField.
Please see code below. Any ideas what's wrong here?
type BaseProps<T> = {
name: string;
convertValue?: (value: T) => T;
};
type CompanyType = {
address: string;
employees: number;
};
type CompanyProps = BaseProps<CompanyType> & {
required?: boolean;
};
const Company = (props: CompanyProps) => {
return <div>{/** */}</div>;
};
type ArrayFieldProps<T, V extends React.FC<BaseProps<T>>> = {
Component: V;
componentProps: React.ComponentProps<V>;
values: T[];
};
const ArrayField = <T, V extends React.FC<BaseProps<T>>>({
Component,
values,
componentProps
}: ArrayFieldProps<T, V>) => {
return (
<React.Fragment>
{values.map((_, index) => (
<Component key={index} {...componentProps} />
))}
</React.Fragment>
);
};
export const App = () => {
const companies: CompanyType[] = [];
return (
<ArrayField
values={companies}
Component={Company}
componentProps={{
name: 'company',
convertValue: (value) => ({
...value,
address: value.address.toUpperCase()
}),
required: true
}}
/>
);
};
I would do it slightly differently. Component types are quite complex so IMO it's easier to reason about simpler types, and it this scenario it solves your problem. Instead of using the Component as a "base" for your interface, use props. Like this:
type BaseProps<T> = {
name: string;
convertValue?: (value: T) => T;
};
type ArrayFieldProps<T, P extends BaseProps<T>> = {
Component: React.ComponentType<P>;
componentProps: P;
values: T[];
};
const ArrayField = <T, P extends BaseProps<T>>({
Component,
values,
componentProps
}: ArrayFieldProps<T, P>) => {
return (
<>
{values.map((_, index) => (
<Component key={index} {...componentProps} />
))}
</>
);
};
So as you can see the main difference is that the second generic type has to extend BaseProps<T> instead of a component type with specific props (this is most likely where TypeScript gives up and it results in problems with key prop) and ultimately you want your Component to be any valid React component (whether it's class or a function one). Of course if you really insist on enforcing function components you can change React.ComponentType to React.FC and it would still work.
You have to take into consideration the
key={index}
portion because there is a mismatch from the expected type and what is passed. This portion is not included in any of the types and I guess typescript just interprets it as value to be passed (not actual key).
You may try to move it on outer div just to see if the situation improves.

Typescript interface extends two component types

So this will output an <input> element - everything works perfectly:
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: FieldError;
icon?: string;
id: string;
register?: UseFormRegisterReturn;
}
const StyledInputComponent = styled.input`
...
`
const MyComponent = ({
error,
icon,
id,
register,
onChange,
...props
}: InputProps) => {
return (
<StyledInputComponent
hasError={!!error}
id={id}
onChange={onChange}
placeholder={placeholder}
type={type}
{...register}
{...props}
/>
});
};
I now need to be able to have the consuming component choose between an <input> and a <textarea>.
The problem I have is that (I think) I need to extend the interface further like this:
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLTextAreaElement> {
error?: FieldError;
icon?: string;
id: string;
inputType?: "input" | "textarea";
register?: UseFormRegisterReturn;
}
And I'm just using the new inputType prop to switch between outputted components:
{ inputType === "input" &&
<StyledInputComponent
hasError={!!error}
id={id}
onChange={onChange}
placeholder={placeholder}
type={type}
{...register}
{...props}
/>
}
{ inputType === "textarea" &&
<StyledTextAreaComponent
hasError={!!error}
id={id}
onChange={onChange}
placeholder={placeholder}
rows={4}
{...register}
{...props}
/>
}
However, trying to extend the interface for both an <input> and a <textarea> has lead to numerous errors, all along these lines:
What's the right way to go about resolving this?
Your component cannot extend the props of input AND textarea because they have they both have properties with some of the same names, but different types.
So your component should only extends one of these, depending on what is passed as the inputType.
That means you need generics here.
For example:
type InputProps<T extends 'input' | 'textarea'> = {
id: string;
inputType?: T;
} & JSX.IntrinsicElements[T]
Here JSX.InstrinsicElements['tagnamehere'] will return the props that react would allow for that tag name. And T is set by the value of inputType, which is either input or textarea.
Now you just need to make you component generic as well, and pass that generic to the props type:
function MyComponent<T extends 'input' | 'textarea'>(props: InputProps<T>) {
return <></>
}
Now to test it out:
// no type errors
const testInput = <MyComponent id="123" inputType='input' checked />
const testTextarea = <MyComponent id="456" inputType='textarea' rows={10} />
// type error, can't use input prop on a textarea
const testBadProps = <MyComponent id="456" inputType='textarea' checked />
// ^ type error
Playground
I've tried your solution, but I have some problems when returning an input text or a textarea
function MyComponent<T extends 'input' | 'textarea'>(props: InputProps<T>) {
const {inputType, ...rest} = props;
if (inputType === "input") {
return <input
// ^error
type={"text"}
{...rest}
/>
}
if (inputType === "textarea") {
return <textarea
// ^error
{...rest}
/>
}
return null
}
This are the returned errors:
Type '{ type: "text"; } & Omit<InputProps<T>, "inputType">' is not assignable to type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.
Type '{ type: "text"; } & Omit<InputProps<T>, "inputType">' is not assignable to type 'ClassAttributes<HTMLInputElement>'.
Types of property 'ref' are incompatible.
Type 'InputProps<T>["ref"] | undefined' is not assignable to type 'LegacyRef<HTMLInputElement> | undefined'.
playground with error
I can solve the problem if the rest variable is casted to JSX.IntrinsicElements["input"] or JSX.IntrinsicElements["textarea"]... but I don't think is the best solution... Any alternative?
playground solved casting the rest variable

Fully static HOC in react and typescript

I have this code:
type AAdds = {
name: string
}
type WithAAdds<T> = T & Partial<AAdds>
const withMyHoc = function<P>(Wrapped: FC<WithAAdds<P>>) {
return (props: P) => {
return (
<Wrapped name="john" {...props} />
)
}
}
const MyComponent = ({name, lastName}: {name:string, lastName: string}) => {
return (
<div>
{name} : {lastName}
</div>
)
}
const MyHocComponent = withMyHoc(MyComponent)
const App = () => {
// Property 'name' is missing in type '{ lastName: string; }' but required in type '{ name: string; lastName: string; }'.ts(2741)
return <MyHocComponent lastName="Doe" />
}
I want the <MyHocComponent /> to be statically typed and without listing name in vs code. Is this possible?
I tried adding this additional type:
type WithoutAAdds<T> = Pick<T, Exclude<keyof AAdds, 'name'>>
And setting the HOC's return type to FC<WithoutAAdds<P>>, but that fails too with
Type '(props: P) => JSX.Element' is not assignable to type 'FC<WithoutAAdds<P>>'.
Types of parameters 'props' and 'props' are incompatible.
Type 'PropsWithChildren<WithoutAAdds<P>>' is not assignable to type 'P'.
'P' could be instantiated with an arbitrary type which could be unrelated to 'PropsWithChildren<WithoutAAdds<P>>'.ts(2322)
This comes from the return signature (props: P).
So "fully static" does not seem to be possible. Perhaps someone can improve this, but it does address the original problem:
The <Wrapped> is statically listing the name, but removing this property does not show the error message in vs code (nay :( ).
The <MyHocComponent> properly lists lastName and no name. Removing lastName does give an error in vs code (yay!).
The only roadblock to a "fully static" HOC is the any for the inner props. I could not find a workaround to make that statically typed. The first observation above seems to stem from this roadblock.
PS: Thanks to #Zbigniew Zagórski and #Alex Chashin for Omit type.
type AAdds = {
name: string
}
type WithAAdds<T> = T & AAdds
type WithoutAAdds<T> = Omit<T, keyof AAdds>
const withMyHoc = function <P>(Wrapped: FC<WithAAdds<P>>): FC<WithoutAAdds<P>> {
return (props: any) => {
return (
<Wrapped name="john" {...props} />
)
}
}
const MyComponent = ({ name, lastName }: { name: string, lastName: string }) => {
return (
<div>
{name} : {lastName}
</div>
)
}
const MyHocComponent = withMyHoc(MyComponent)
const App = () => {
return <MyHocComponent lastName="Doe" />
}
The first thing I did was copy and paste your code into the TypeScript Playground. By hovering over withMyHoc(MyComponent) I can see the inferred type for P is {name: string; lastName: string}. But we want P to be {lastName: string} only, and for name to be added by WithAAdds.
So then I tried to see if it would work I explicitly set the value for P rather than letting it be inferred. Like this:
const MyHocComponent = withMyHoc<{lastName: string}>(MyComponent)
This revealed your first error. T & Partial<AAdds> says the name property is optional on the outer component because of the Partial. This won't work because MyComponent requires name.
You need to make the added properties be requirements.
type WithAAdds<T> = T & AAdds
This is progress, because now the above code where we manually specified withMyHoc<{lastName: string}> will work, and name won't be required in MyHocComponent.
You can also do this:
const MyHocComponent = withMyHoc<{lastName: string}>(({name, lastName}) => {
return (
<div>
{name} : {lastName}
</div>
)
})
But we still aren't getting the proper type inference.
The Exclude/Omit approach (Omit uses Exclude under the hood) allows for automatic type inference of P, but it introduces a possibility for an edge case problem where P could be {name: "Bob", lastName: string}. That's why you get the dreaded "could be instantiated with an arbitrary type" error.
If you want to ignore that edge case, you can just assert correctness.
const withMyHoc = function<P extends AAdds>(Wrapped: FC<P>) {
return (props: Omit<P, keyof AAdds>) => {
return (
<Wrapped {...{...props, name: "john"} as P} />
)
}
}
This version is more readable, but less precise, since we are asserting that props is P when we know that it isn't because it's missing the name.
const withMyHoc = function<P extends AAdds>(Wrapped: FC<P>) {
return (props: Omit<P, keyof AAdds>) => {
return (
<Wrapped {...props as P} name="john" />
)
}
}

React with Typescript -- Generics while using React.forwardRef

I am trying to create a generic component where a user can pass the a custom OptionType to the component to get type checking all the way through. This component also required a React.forwardRef.
I can get it to work without a forwardRef. Any ideas? Code below:
WithoutForwardRef.tsx
export interface Option<OptionValueType = unknown> {
value: OptionValueType;
label: string;
}
interface WithoutForwardRefProps<OptionType> {
onChange: (option: OptionType) => void;
options: OptionType[];
}
export const WithoutForwardRef = <OptionType extends Option>(
props: WithoutForwardRefProps<OptionType>,
) => {
const { options, onChange } = props;
return (
<div>
{options.map((opt) => {
return (
<div
onClick={() => {
onChange(opt);
}}
>
{opt.label}
</div>
);
})}
</div>
);
};
WithForwardRef.tsx
import { Option } from './WithoutForwardRef';
interface WithForwardRefProps<OptionType> {
onChange: (option: OptionType) => void;
options: OptionType[];
}
export const WithForwardRef = React.forwardRef(
<OptionType extends Option>(
props: WithForwardRefProps<OptionType>,
ref?: React.Ref<HTMLDivElement>,
) => {
const { options, onChange } = props;
return (
<div>
{options.map((opt) => {
return (
<div
onClick={() => {
onChange(opt);
}}
>
{opt.label}
</div>
);
})}
</div>
);
},
);
App.tsx
import { WithoutForwardRef, Option } from './WithoutForwardRef';
import { WithForwardRef } from './WithForwardRef';
interface CustomOption extends Option<number> {
action: (value: number) => void;
}
const App: React.FC = () => {
return (
<div>
<h3>Without Forward Ref</h3>
<h4>Basic</h4>
<WithoutForwardRef
options={[{ value: 'test', label: 'Test' }, { value: 1, label: 'Test Two' }]}
onChange={(option) => {
// Does type inference on the type of value in the options
console.log('BASIC', option);
}}
/>
<h4>Custom</h4>
<WithoutForwardRef<CustomOption>
options={[
{
value: 1,
label: 'Test',
action: (value) => {
console.log('ACTION', value);
},
},
]}
onChange={(option) => {
// Intellisense works here
option.action(option.value);
}}
/>
<h3>With Forward Ref</h3>
<h4>Basic</h4>
<WithForwardRef
options={[{ value: 'test', label: 'Test' }, { value: 1, label: 'Test Two' }]}
onChange={(option) => {
// Does type inference on the type of value in the options
console.log('BASIC', option);
}}
/>
<h4>Custom (WitForwardRef is not generic here)</h4>
<WithForwardRef<CustomOption>
options={[
{
value: 1,
label: 'Test',
action: (value) => {
console.log('ACTION', value);
},
},
]}
onChange={(option) => {
// Intellisense SHOULD works here
option.action(option.value);
}}
/>
</div>
);
};
In the App.tsx, it says the WithForwardRef component is not generic. Is there a way to achieve this?
Example repo: https://github.com/jgodi/generics-with-forward-ref
Thanks!
Creating a generic component as output of React.forwardRef is not directly possible 1 (see bottom). There are some alternatives though - let's simplify your example a bit for illustration:
type Option<O = unknown> = { value: O; label: string; }
type Props<T extends Option<unknown>> = { options: T[] }
const options = [
{ value: 1, label: "la1", flag: true },
{ value: 2, label: "la2", flag: false }
]
Choose variants (1) or (2) for simplicity. (3) will replace forwardRef by usual props. With (4) you globally chance forwardRef type definitions once in the app.
Playground variants 1, 2, 3
Playground variant 4
1. Use type assertion ("cast")
// Given render function (input) for React.forwardRef
const FRefInputComp = <T extends Option>(p: Props<T>, ref: Ref<HTMLDivElement>) =>
<div ref={ref}> {p.options.map(o => <p>{o.label}</p>)} </div>
// Cast the output
const FRefOutputComp1 = React.forwardRef(FRefInputComp) as
<T extends Option>(p: Props<T> & { ref?: Ref<HTMLDivElement> }) => ReactElement
const Usage11 = () => <FRefOutputComp1 options={options} ref={myRef} />
// options has type { value: number; label: string; flag: boolean; }[]
// , so we have made FRefOutputComp generic!
This works, as the return type of forwardRef in principle is a plain function. We just need a generic function type shape. You might add an extra type to make the assertion simpler:
type ForwardRefFn<R> = <P={}>(p: P & React.RefAttributes<R>) => ReactElement |null
// `RefAttributes` is built-in type with ref and key props defined
const Comp12 = React.forwardRef(FRefInputComp) as ForwardRefFn<HTMLDivElement>
const Usage12 = () => <Comp12 options={options} ref={myRef} />
2. Wrap forwarded component
const FRefOutputComp2 = React.forwardRef(FRefInputComp)
// ↳ T is instantiated with base constraint `Option<unknown>` from FRefInputComp
export const Wrapper = <T extends Option>({myRef, ...rest}: Props<T> &
{myRef: React.Ref<HTMLDivElement>}) => <FRefOutputComp2 {...rest} ref={myRef} />
const Usage2 = () => <Wrapper options={options} myRef={myRef} />
3. Omit forwardRef alltogether
Use a custom ref prop instead. This one is my favorite - simplest alternative, a legitimate way in React and doesn't need forwardRef.
const Comp3 = <T extends Option>(props: Props<T> & {myRef: Ref<HTMLDivElement>})
=> <div ref={myRef}> {props.options.map(o => <p>{o.label}</p>)} </div>
const Usage3 = () => <Comp3 options={options} myRef={myRef} />
4. Use global type augmentation
Add following code once in your app, perferrably in a separate module react-augment.d.ts:
import React from "react"
declare module "react" {
function forwardRef<T, P = {}>(
render: (props: P, ref: ForwardedRef<T>) => ReactElement | null
): (props: P & RefAttributes<T>) => ReactElement | null
}
This will augment React module type declarations, overriding forwardRef with a new function overload type signature. Tradeoff: component properties like displayName now need a type assertion.
1 Why does the original case not work?
React.forwardRef has following type:
function forwardRef<T, P = {}>(render: ForwardRefRenderFunction<T, P>):
ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
So this function takes a generic component-like render function ForwardRefRenderFunction, and returns the final component with type ForwardRefExoticComponent. These two are just function type declarations with additional properties displayName, defaultProps etc.
Now, there is a TypeScript 3.4 feature called higher order function type inference akin to Higher-Rank Types. It basically allows you to propagate free type parameters (generics from the input function) on to the outer, calling function - React.forwardRef here -, so the resulting function component is still generic.
But this feature can only work with plain function types, as Anders Hejlsberg explains in [1], [2]:
We only make higher order function type inferences when the source and target types are both pure function types, i.e. types with a single call signature and no other members.
Above solutions will make React.forwardRef work with generics again.
I discovered this question from reading this blog post, and I think there is a more straight-forward way of accomplishing this than the current accepted answer has proposed:
First we define an interface to hold the type of the component using something called a call signature in typescript:
interface WithForwardRefType extends React.FC<WithForwardRefProps<Option>> {
<T extends Option>(props: WithForwardRefProps<T>): ReturnType<React.FC<WithForwardRefProps<T>>>
}
Notice how the function signature itself is declared as generic, not the interface - this is the key to making this work. The interface also extends React.FC in order to expose some useful Component properties such as displayName, defaultProps, etc.
Next we just supply that interface as the type of our component, and without having to specify the type of the props, we can pass that component to forwardRef, and the rest is history...
export const WithForwardRef: WithForwardRefType = forwardRef((
props,
ref?: React.Ref<HTMLDivElement>,
) => {
const { options, onChange } = props;
return (
<div ref={ref}>
{options.map((opt) => {
return (
<div
onClick={() => {
onChange(opt);
}}
>
{opt.label}
</div>
);
})}
</div>
);
});
Sandbox link here
References:
https://stackoverflow.com/a/73795451/2089675

Extends type of accepted props

I have a component that's root node can be overridden with the value of passed prop.
interface Props {
propA?: string
as?: React.ElementType
}
const OverridableComponent = (props: Props) => {
const { as: Tag = 'div', ...otherProps } = props
return <Tag {...props} />
}
And another component that will be used as as.
interface SomeComponentProps {
propB: string //<-- mandatory prop
children?: React.ReactNode
}
const SomeComponent = (props: SomeComponentProps) => {
return <div someProp={props.propB}>{props.children}</div>
}
So, the result that I would love to achieve is something like this
<OverridableComponent propA='optionalProp' as={SomeComponent} />
and I'd like OverridableComponent to merge props from SomeComponent.
<OverridableComponent propA='optionalProp' as={SomeComponent}
propB={/*required form SomeComponentProps */'someValue />
To implement this I tried to use generics.
type Props<T extends ElementType> = {
propA?: string
as?: T
} & React.ComponentProps<T>
it works for the case when as prop is passed, but if it's not, every passed prop even from='string' is allowed, because it's allowed on SVG attributes of React.ElementType.
Any idea how to improve this case?
Is it viable for you to pass the required React.ElementType explicitely as Generic type arguments in JSX? Doing that, you do not rely on compiler inference and can narrow the type properly.
Component:
const AppWithComponent = () => (
<OverridableComponent<typeof SomeComponent>
propA="pa"
propB="pb"
// className="ajsf" ; error
// from='string' ; error
as={SomeComponent}
/>
);
Intrinsic Element:
const AppWithIntrinsicElementExplicit = () => (
<OverridableComponent<"div">
propA="pa"
// propB="pb" ; error
className="yehaa"
// from='string' ; error
as="div" // you also can drop this attribute
/>
);
Codesandbox

Resources