6

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.

Misha Moroshko
  • 166,356
  • 226
  • 505
  • 746

4 Answers4

8

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.

1. Component declares its own protocol

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.

2. Limitation in TS type system

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.

—-

Update

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.

hackape
  • 18,643
  • 2
  • 29
  • 57
0

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/>
};
HenriDev
  • 587
  • 7
  • 13
  • 1
    The question mentions a `Form` that drives the type. Your answer doesn't make much sense since I don't see a `Form` in it. – Misha Moroshko Apr 19 '21 at 01:48
0

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

https://www.typescriptlang.org/play?#code/C4TwDgpgBAlgdjYMCGAbAamgrhAzlAXigG8BYAKCigDMYAnXYAOWQFsIAuKRu+AcwDcFKqmSMW7Lj35DKUZH07dgvOHwoBfChVCRYcMFmZtoRANYQQAe2r7EKDNjyyKqCMHmKu8Q8faEoACIFCEDZNw9WGAATaLcJJR8jBIDAqNj4kzCgA

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?

Michael Hoobler
  • 622
  • 5
  • 14
  • 2
    You're missing a key point. How would the `Input` be able to do `keyof` on the `initialValues` passed to the parent `Form`? – Misha Moroshko Apr 16 '21 at 08:16
0

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>
johannchopin
  • 13,720
  • 10
  • 55
  • 101
  • The downside of this approach is that form components won't be tree-shakeable. Imagine you have 10 different form input components like `Input`, `Select`, `RadioGroup`, etc. If a user creates a simple form with 2 `Input` fields, they should include only the `Form` and the `Input` component in their bundle. With your pproach, all 10 components will be included since they all defined on the `Form`. – Misha Moroshko Apr 19 '21 at 01:45
  • You are right and that's why this component need to be a specific one (like `LoginForm`) that you are sure to consume (there is no reason to create it if you don't consume it). – johannchopin Apr 19 '21 at 08:43