6

I have a component that gets used multiple times in succession with some identical properties and some unique properties:

interface InsideComponentProps {
    repeatedThing: string;
    uniqueThing: string;
}

const InsideComponent: React.SFC<InsideComponentProps> = ({ repeatedThing, uniqueThing }) => (
    <div>{repeatedThing} - {uniqueThing}</div>
);

const Example = () => (
    <div>
        <InsideComponent repeatedThing="foo" uniqueThing="1" />
        <InsideComponent repeatedThing="foo" uniqueThing="2" />
        <InsideComponent repeatedThing="foo" uniqueThing="3" />
    </div>
);

The duplicate repeatedThing properties bother me, so I'm seeking a way to remove that redundancy. One thing I've done in non-TypeScript applications is to introduce a wrapper component that clones all of the children, adding the repeated properties in the process:

interface OutsideComponentProps {
    repeatedThing: string;
}

const OutsideComponent: React.SFC<OutsideComponentProps> = ({ repeatedThing, children }) => (
    <div>
        {React.Children.map(children, (c: React.ReactElement<any>) => (
            React.cloneElement(c, { repeatedThing })
        ))}
    </div>
);

const Example = () => (
    <OutsideComponent repeatedThing="foo">
        <InsideComponent uniqueThing="1" />
        <InsideComponent uniqueThing="2" />
        <InsideComponent uniqueThing="3" />
    </OutsideComponent>
);

The resulting JavaScript code has the behavior I want, but the TypeScript compiler has errors because I'm not passing all of the required properties when I instantiate InsideComponent:

ERROR in [at-loader] ./src/index.tsx:27:26
    TS2322: Type '{ uniqueThing: "1"; }' is not assignable to type 'IntrinsicAttributes & InsideComponentProps & { children?: ReactNode; }'.
  Type '{ uniqueThing: "1"; }' is not assignable to type 'InsideComponentProps'.
    Property 'repeatedThing' is missing in type '{ uniqueThing: "1"; }'.

The only solution I've thought of is to mark InsideComponents repeatedThing property as optional, but that's not ideal because the value is required.

How can I preserve the strictness of making sure that InsideComponent does actually receive all of the props while reducing the duplication of the properties at the call site?

I'm using React 16.2.0 and TypeScript 2.6.2.

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
  • Note that specifying callback argument types, while not the cause here, can both create awkward type errors and hide real bugs. `map(components, (c: React.ReactElement) => ...)` should be `map, components, c => ...)` – Aluan Haddad Feb 01 '18 at 17:04
  • @AluanHaddad I gathered that from [How to assign the correct typing to React.cloneElement when giving properties to children?](https://stackoverflow.com/q/42261783/155423); are you saying the answer is incorrect? – Shepmaster Feb 01 '18 at 18:20
  • 1
    No, I am not saying that answer is incorrect. He is using a type assertion `c as ReactElement`, which is good style when necessary, as in that answer. You are hiding the type assertion behind a `(c: ReactElement) =>`. This is bad style because, you are actually using an assertion `as`, but we only know that by reading the definition of `map`. – Aluan Haddad Feb 01 '18 at 19:11

3 Answers3

2

TypeScript checks to make sure that you assign all required properties to the React element. Since you assign the extra properties in the OutsideComponent the compiler can't really check that.

One option would be to specify children as a function that takes extra properties as a parameter and spread them to InsideComponent. The syntax is a bit more convoluted but it is more type safe:

interface OutsideComponentProps {
    repeatedThing: string;
    children: (outerProps: OutsideComponentProps) => React.ReactElement<any>;
}

const OutsideComponent: React.SFC<OutsideComponentProps> = (o) => o.children(o);

const Example = () => (
    <OutsideComponent repeatedThing="foo">{(o) => 
        <div>
            <InsideComponent uniqueThing="1" {...o} />
            <InsideComponent uniqueThing="2" {...o} />
            <InsideComponent uniqueThing="3" {...o} />
        </div>
    }</OutsideComponent>
);

It seems that OutsideComponent is very abstract and thus reusable; is there any way to convert it into a very generic component that takes all of its props and provides them as the argument, without having to define an OutsideComponentProps for each case?

While you can use generic functions as components, you can't specify type parameters explicitly, they can only be inferred. This is a drawback, but ultimately it can be worked around.

function GenericOutsideComponent<T>(props: { children: (o: T) => React.ReactElement<any> } & Partial<T>, context?: any): React.ReactElement<any> {
    return props.children(props as any);
}

const Example = () => (
    <GenericOutsideComponent repeatedThing="foo">{(o: InsideComponentProps) =>
        <div>
            <InsideComponent uniqueThing="1" {...o} />
            <InsideComponent uniqueThing="2" {...o} />
            <InsideComponent uniqueThing="3" {...o} />
        </div>
    }</GenericOutsideComponent>
);

Similar to your original JavaScript solution, there is the risk that some required properties of InsideComponent are not specified as the properties of GenericOutsideComponent are Partial<T> (to allow only repeatedThing to be specified) and o is T because otherwise the compiler will consider repeatedThing is not specified and will require it on InsideComponent. If setting a dummy value on InsideComponent is not a problem, just change the signature of children to (o: Partial<T>) => React.ReactElement<any> but this is less then ideal.

Another option is to be explicit about which properties are on GenericOutsideComponent using a Pick:

function GenericOutsideComponent<T>(props: { children: (o: T) => React.ReactElement<any> } & T, context?: any): React.ReactElement<any> {
    return props.children(props);
}

const Example = () => (
    <GenericOutsideComponent repeatedThing="foo">{(o: Pick<InsideComponentProps, "repeatedThing">) =>
        <div>
            <InsideComponent uniqueThing="1" {...o} />
            <InsideComponent uniqueThing="2" {...o} />
            <InsideComponent uniqueThing="3" {...o} />
        </div>
    }</GenericOutsideComponent>
);
Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • I had not thought about using a function for `children`! It seems that `OutsideComponent` is very abstract and thus reusable; is there any way to convert it into a very generic component that takes all of its props and provides them as the argument, without having to define an `OutsideComponentProps` for each case? – Shepmaster Jan 25 '18 at 04:45
  • 1
    @Shepmaster I think I figured out a workable generic solution, have a look. The bounty was definitely a motivator :P – Titian Cernicova-Dragomir Jan 27 '18 at 18:58
1

I'm new to TypeScript, but this may be an alternative:

// OutsideComponent.tsx
import * as React from "react";

const OutsideComponent: React.SFC<{ children?: React.ReactNode, [rest: string]: any }> = (props) => {
    const { children, ...rest } = props;

    return (
        <div>
            {React.Children.map(children, ((child, i) =>
                React.cloneElement(child as React.ReactElement<any>, { key: i, ...rest }))
            )}
        </div>
    )
};

type Sub<
    O extends string,
    D extends string
    > = {[K in O]: (Record<D, never> & Record<string, K>)[K]}[O]

export type Omit<O, D extends keyof O> = Pick<O, Sub<keyof O, D>>

export default OutsideComponent;

(Omit type taken from this answer)

Then

import OutsideComponent, { Omit } from './OutsideComponent';

const Example = (): React.ReactElement<any> => {
    const PartialInsideComponent: React.SFC<Omit<InsideComponentProps, 'repeatedThing'>> = InsideComponent;

    return (
        <OutsideComponent repeatedThing="foo">
            <PartialInsideComponent uniqueThing="1" />
            <PartialInsideComponent uniqueThing="2" />
            <PartialInsideComponent uniqueThing="3" />
        </OutsideComponent>
    )
};
Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
alepeino
  • 9,551
  • 3
  • 28
  • 48
1

I came up with a full solution that does what is wanted, while still providing type safety and code hints using the VSCode setup I have.

Partialize.tsx

import React, { cloneElement } from "react";

/**
 * This HOC takes a `Component` and makes some of it's props optional as defined by `partialProps`
 *
 * @param Component The component to make some props optional
 * @param partialProps The properties to make partial
 */
export const withPartialProps = <
  TProps extends {},
  TKeys extends (keyof TProps)[]
>(
  Component: React.ComponentType<TProps>,
  partialProps: TKeys
) => {
  type TPartialProps = typeof partialProps[number];

  return (
    props: Omit<TProps, TPartialProps> & Partial<Pick<TProps, TPartialProps>>
  ) => {
    partialProps.forEach((propName) => {
      if (props[propName] === undefined) {
        throw Error(`${propName} is undefined`);
      }
    });

    return <Component {...(props as TProps)} />;
  };
};

/**
 * This HOC takes a `Component` and returns two components, the partial version created by `withPartialProps`,
 * and a wrapper component that will provide the now optional props to the child elements;
 *
 * @param Component The component to partialize
 * @param partialProps The properties to make partial
 *
 * @see withPartialProps
 */
export const partialize = <TProps extends {}, TKeys extends (keyof TProps)[]>(
  Component: React.ComponentType<TProps>,
  partialProps: TKeys
) => {
  type TPartialProps = typeof partialProps[number];
  type TChildProps = Omit<TProps, TPartialProps> &
    Partial<Pick<TProps, TPartialProps>>;
  type TWrapperProps = Pick<TProps, TPartialProps> & {
    children:
      | React.ReactElement<TChildProps>[]
      | React.ReactElement<TChildProps>;
  };

  return {
    Partial: withPartialProps(Component, partialProps),
    PartialWrapper: ({ children, ...props }: TWrapperProps) => (
      <>
        {React.Children.map(children, (child) =>
          cloneElement(child, props as any)
        )}
      </>
    )
  };
};

Demo.tsx

import React from "react";
import { partialize } from "./Partialize";

type FooProps = {
  x: number;
  y: string;
  z: "a" | "b" | "c";
};

const Foo = ({ x, y, z }: FooProps) => (
  <p>
    x: {x} | y: {y} | z: {z}
  </p>
);

export const Bar = partialize(Foo, ["y"]);
export const Baz = partialize(Foo, ["z"]);

Example usage

<Baz.PartialWrapper z="b">
  <Baz.Partial x={1} y="Text #1" />
  <Baz.Partial x={2} y="Text #2" />
</Baz.PartialWrapper>

CodeSandbox link

The end result is some rather messy types, but should fulfill the needs of the question.

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366