Reputation: 171509
Given the following Form
and its (uncontrolled) Input
children:
<Form initialValues={{ firstName: "", lastName: "", age: "" }}>
<Input label="First name" name="firstName" />
<Input label="Last name" name="lastName" />
<Input label="Age" name="age" />
</Form>
I'd like the Input
's name
prop to be of type "firstName" | "lastName" | "age"
. This type should be derived from the Form
's initialValues
.
What's the cleanest way to achieve this?
Note: In the general case, form input components should be tree-shakeable.
Upvotes: 6
Views: 3713
Reputation: 20007
This piece of information cannot be automatically inferred. You'll have to manually provide the type one way or another. And ad-hoc solution would be like:
function App() {
const initialValues = {
firstName: 'string',
lastName: 'string',
age: 'string',
}
const MyInput: React.FC<{ label: string, name: keyof typeof initialValues }> = Input
return <Form initialValues={initialValues}>
<MyInput label="First name" name="firstNameWRONG" /> // error
<MyInput label="Last name" name="lastName" />
<MyInput label="Age" name="age" />
</Form>
}
I'd like to talk about why it's impossible.
I guess your mind model is to see child component as a "argument" to parent component, so it's reasonable to pose some kind of "requirement" from parent to child.
I wouldn't say such point of view is totally wrong, cus in practice parent-child component could be written in a coupling fashion, but it's not idiomatic in react.
Ideally, a react component should announce its own "protocol" through props type
. You may think of a component as a "service", it's the responsibility of the service consumer to comply with the protocol, not the other way around.
Transpiled to JS, such structure becomes:
parentElement = React.createElement(Parent, parentProps,
(childElement = React.createElement(Child, childProps))
)
First, if any type error were to be raised, it should be raise by the parentElement
line, not childElement
. What is violated is the protocol of parent component, which states "child component's name
should be keyof initValue
". And such verification is done by React.createElement
function against its argument.
Second, from type system point of view, if we were able to infer childProps
's type from parentProps
, then the resolution process should goes like:
1. let `Parent` be generic type of form
Component<T, E<_>> = (props: { initValues: T, children: E<keyof T> }) => Element<any>
2. let `Child` be generic type of form
Component<K> = (props: { name: K }) => Element<K>
3. from `childElement = React.createElement(Child, childProps))`
we know `childElement` is type `Element<string>`
4. from `parentElement = React.createElement(Parent, parentProps, childElement)`
we know about `T` and `E = Element` and `_ = keyof T`
5. now we need to allow prioritze `E<_>` rule over `Element<string>`, thus override `childElement` from `Element<string>` to `Element<keyof T>`
For such type system to work, we need to both support higher-kinded type parameter E<_>
and also some sort of precedence of type operation.
Effectively we need to specify that childElement
must not be resolved yet, but remain at a pending state of Element<_>
, and then let the next resolution step to fill-in _
part.
Plus, we don't mean anything like,
Element<T>.fill(arg: T)
But we mean,
fill<T>(arg0: T, arg1: Element<T>)
AFAIK, there's never a type system support such behavior, not to mention that TS doesn't even support higher-kinded type to begin with.
—-
I think it’s worth mentioning that, it’s theoretically possible to raise type error from Form
about name
prop of Input
not complying with protocol. However it cannot be done with JSX, only possible through React.createElement
.
This is due to TS assigns all JSX created elements the special builtin interface JSX.Element
. And react has augmented it to extend React.ReactElement<any, any>
. The any
thing effectively max out the props
protocol of all elements, making any restriction impossible.
I tried to find workaround but unfortunately nothing found. The best you can get is what suggested in the link provided in comment.
Upvotes: 8
Reputation: 14873
You just here need to restrict the prop name
of your Input
component and it can be achieved like that:
export type InputName = "firstName" | "lastName" | "age"
export interface InputProps {
name: InputName
}
const Input: React.FC<InputProps> = (props) => {
const { name } = props
// ...
}
However that means that each Input
in your app should have one these names, and I'm pretty sure that this is not what you want.
To be sure that each Input
in a specific Form
component have one of these name, you will need to define a new custom component that will extend the default behaviour:
SpecificForm.tsx
import Form from 'components/Form'
import Input, { InputProps } from 'components/Input'
export type SpecificInputName = "firstName" | "lastName" | "age"
export interface SpecificInputProps extends InputProps {
name: InputName
}
export type SpecificFormComponent = React.FC & { Input: React.FC<SpecificInputProps> }
const SpecificForm: SpecificFormComponent = (props) => {
return <Form {...props}>{props.children}</Form>
}
const SpecificInput: React.FC<SpecificInputProps> = (props) => {
return <Input {...props} />
}
SpecificForm.Input = SpecificInput
export default SpecificForm
Then you can consume it just like that:
import Form from './components/SpecificForm'
<Form initialValues={{ firstName: "", lastName: "", age: "" }}>
<Form.Input label="First name" name="firstName" />
<Form.Input label="Last name" name="lastName" />
<Form.Input label="Age" name="age" />
</Form>
Upvotes: 0
Reputation: 622
You could use keyof
type initialValues = {
firstName: string;
lastName: string;
age: string
}
type inputName = keyof initialValues;
let age: inputName = "age"; //valid
let middleName: inputName = "middleName"; //invalid
Edit: I feel like I'm missing something, but here's a code sandbox kind of showing how I would implement it:
https://codesandbox.io/s/happy-monad-zo0p5?file=/src/App.tsx
Is something like that not possible? Is Form
and Input
being provided by a third party module?
Upvotes: 0
Reputation: 697
not sure how your input is defined but i would create a type of formname and an interface for your input component using the formname type
type FormName = "firstName" | "lastName" | "age";
interface InputProps {
name: FormName
}
//or
interface InputProps {
name: "firstName" | "lastName" | "age"
}
const Input: FC<InputProps> = (props): JSX.Element => {
<input {...props}><input/>
};
Upvotes: 0