Misha Moroshko
Misha Moroshko

Reputation: 171509

TypeScript: How to pass type information from parent to child?

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

Answers (4)

hackape
hackape

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.

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.

Upvotes: 8

johannchopin
johannchopin

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

Michael Hoobler
Michael Hoobler

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

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?

Upvotes: 0

HenriDev
HenriDev

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

Related Questions