Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React/tsx: child unable to infer correct generic type from parent

I'd like to take some code that is able to correctly determine when my types are incompatible, and use children instead of a prop. Here's the baseline that errors correctly:

type Option<T> = {
  value: T;
  text: string;
}

type SelectProps<T> = {
  value: T;
  options: Option<T>[];
}
const options = [
  { value: 5, text: 'Five' },
  { value: 10, text: 'Ten'}
];
return <Select value="Text" options={options} />; // ERROR: value isn't type number

However I can't seem to get this to error when I use children:

type OptionProps<T> = {
  value: T;
  children: string;
}

type SelectProps<T> {
  value: T;
  children: React.ReactElement<OptionProps<T>>;
}
/* No errors here */

<Select value="Text">
  <Option value={5}>Five</Option>
  <Option value={10}>Ten</Option>
</Select>

I put together a more complete example in this codesandbox (the code from the sandbox can be found below): https://codesandbox.io/s/throbbing-sun-tg48b - Note how renderA correctly identifies the error, where as renderB incorrectly has no errors.

import * as React from 'react';

type OptionA<T> = {
  value: T;
  text: string;
}

type SelectAProps<T> = {
  value: T;
  options: OptionA<T>[];
  onClick: (value: T) => void;
}

class SelectA<T> extends React.Component<SelectAProps<T>> {
  renderOption = (option: OptionA<T>) => {
    const { value, text } = option;
    const onClick = () => {
      this.props.onClick(value)
    };
    return <div onClick={onClick}>{text}</div>
  }

  render(): React.ReactNode {
    return <div>{this.props.options.map(this.renderOption)}</div>
  }
}

type OptionBProps<T> = {
  value: T;
  children: string;
}

class OptionB<T> extends React.Component<OptionBProps<T>> {}

type SelectBProps<T> = {
  value: T;
  children: React.ReactElement<OptionBProps<T>>[];
  onClick: (value: T) => void;
}

class SelectB<T> extends React.Component<SelectBProps<T>> {
  renderOption = (option: OptionB<T>) => {
    const { value, children } = option.props;
    const onClick = () => {
      this.props.onClick(value)
    };
    return <div onClick={onClick}>{children}</div>
  }

  render(): React.ReactNode {
    return <div>{React.Children.map(this.props.children, this.renderOption)}</div>
  }
}

class Main extends React.Component {
  onClick(value: string) {
    console.log(value);
  }

  renderA(): React.ReactNode {
    const options = [
      { value: 5, text: 'Five' },
      { value: 10, text: 'Ten'}
    ]
    return <SelectA value="Text" options={options} onClick={this.onClick} />
  }

  renderB(): React.ReactNode {
    return (
      <SelectB value="Text" onClick={this.onClick}>
        <OptionB value={5}>Five</OptionB>
        <OptionB value={10}>Ten</OptionB>
      </SelectB>
    );
  }
}
like image 732
Wex Avatar asked Jul 17 '19 16:07

Wex


1 Answers

Up to now, unfortunately you cannot enforce type constraints on children props like illustrated in your example. Before, I actually thought that this kind of type checking would be possible. Being curious, I researched a bit:

TypeScript will convert your JSX code to a vanilla React.createElement statement via the default JSX factory function. The return type of React.createElement is JSX.Element (global JSX namespace), which extends React.ReactElement<any>.

What that means is, when you render SelectB<T>, your OptionB components will be represented as JSX.Element[] aka React.ReactElement<any>[].

Given your props type

type SelectBProps<T> = {
  value: T;
  children: React.ReactElement<OptionBProps<T>>[];
  onClick: (value: T) => void;
}

, TypeScript is fine with comparing ReactElement<OptionBProps<T>>[] to ReactElement<any>[] and compiles successfully (given some concrete type for T).

What TypeScript docs say

By default the result of a JSX expression is typed as any. You can customize the type by specifying the JSX.Element interface. However, it is not possible to retrieve type information about the element, attributes or children of the JSX from this interface. It is a black box.

You can prove this behavior (that JSX expressions resolve to JSX.Element): Simply replace SelectBProps by:

type SelectBProps<T> = {  ... ;  children: number[]; ... };

For each OptionB, you now get the error:

Type 'Element' is not assignable to type 'number'

Locally scoped JSX namespaces (additional info)

Since TypeScript 2.8, locally scoped JSX namespaces are supported, which can help to use custom types/type checking for JSX.Element.

TypeScript docs on locally scoped JSX namespaces

JSX type checking is driven by definitions in a JSX namespace, for instance JSX.Element for the type of a JSX element, and JSX.IntrinsicElements for built-in elements. Before TypeScript 2.8 the JSX namespace was expected to be in the global namespace, and thus only allowing one to be defined in a project. Starting with TypeScript 2.8 the JSX namespace will be looked under the jsxNamespace (e.g. React) allowing for multiple jsx factories in one compilation.

In addition, one quote from this very helpful article:

TypeScript supports locally scoped JSX to be able to support various JSX factory types and proper JSX type checking per factory. While current react types use still global JSX namespace, it’s gonna change in the future.

-

Finally, it may be also reasonable to declare children types

  • for documentation purposes
  • to mark it as mandatory (default is optional property)
  • to distinguish a single JSX.Element from an array of elements.

If you still want to have extensive type checking, maybe sticking to your previous solution with props may be a valid idea? At last, children are just another form of props and Render Props have established as a solid concept.

While that does not solve your original children type checking case, I hope to have cleared things up a bit (as for me, it did 😊)!

Cheers

like image 74
ford04 Avatar answered Sep 30 '22 05:09

ford04