1

In this case I'm trying to make a HOC that would take control of a component that can't function without it.

In more detail: a HOC that takes a component that expects props value and onChange and returns a component that no longer expects those props, because they are taken care of.

This is what I got so far:

interface ControlledInput {
    value: string
    onChange: (newValue: string) => void
}

function withControl<T extends ControlledInput>(Elem: FC<T>){

    return (props: Omit<T, keyof ControlledInput>) => {

        const [value, setValue] = React.useState('')

        return <Elem value={value} onChange={setValue} {...props} /> // error
    }
}

However typescript gives me an error that props I'm passing to Elem are not assignable to type T. Link to codesandbox.

The error I get is discussed and explained here. TL;DR: Never assign concrete types to generic parameter, consider it read-only. Ironic that this conclusion is stated as solution. That's not a solution, I need to assign these props.

I've tried not using generic at all, but than prop types of a resulting component are lost.

Type casting helps but isn't it the same as putting any everywhere? If I just wanted to not see red squiggly lines I would use JavaScript

function withControl<T extends ControlledInput>(Elem: FC<T>) {
    return (props: Omit<T, keyof ControlledInput>) => {

        const [value, setValue] = React.useState('')

        return <Elem {...{ value, onChange: setValue, ...props } as T} /> // error is gone
    }
}

In ideal world I would like to tell TypeScript that I only care that component passed as Elem has props value, onChange and whatever else (the T). And resulting component expects as props what is left (the T). That's it, not very difficult on paper yet really difficult to describe to TS.

Alex l.
  • 213
  • 1
  • 14

2 Answers2

1

The error:

  'T' could be instantiated with an arbitrary type which could be unrelated to '{ value: string; onChange: Dispatch<SetStateAction<string>>; }'.ts(2322)

is correct.

Consider this example:

interface ControlledInput {
  value: string;
  onChange: (newValue: string) => void;
}

interface FooProps {
  value: string;
  onChange: (newValue: string) => void;
  name: "hello";
}

const Foo: FC<FooProps> = () => null;

const withControl = <T extends ControlledInput>(Elem: FC<T>) => () =>
  // props: Omit<T, keyof ControlledInput>
  {
    const [value, setValue] = React.useState("");

    return <Elem value={value} onChange={setValue} />;
  };

withControl(Foo);

FooProps is a subtype of ControlledInput. This line withControl(Foo) compiles. But you ended up in a situation where Foo has required prop name whereas you have provided only value and onChange.

In order to fix it, just get rid of generic:

interface ControlledInput {
  value: string;
  onChange: (newValue: string) => void;
}

interface FooProps {
  value: string;
  onChange: (newValue: string) => void;
  name: "hello";
}

const Foo: FC<FooProps> = () => null;

const withControl = (Elem: FC<ControlledInput>) => () =>
  // props: Omit<T, keyof ControlledInput>
  {
    const [value, setValue] = React.useState("");

    return <Elem value={value} onChange={setValue} />;
  };

withControl(Foo); // expected error

SAFE FIX

could you please provide an example, where prop types of component passed as argument carry over to resulting component

You can use this pattern:

import React, { FC } from 'react'


interface MainProps {
  value: string;
  onChange: (value: string) => void
  name: string;
}

type ControlProps = {
  children: (value: string, setValue: (value: string) => void) => JSX.Element
}

const Control: FC<ControlProps> = ({ children }) => {
  const [value, setValue] = React.useState('');

  return children(value, setValue)
}

const Foo = (props: MainProps) => <div></div>;


const App =() => {
  <Control>{
    (value, onChange) =>
      <Foo value={value} onChange={onChange} name='hello' />
  }
  </Control>
}

Playground

UNSAFE FIX

If you still want to stick with your approach, you can turn off this flag:strictFunctionTypes.

In general it is unsafe what you are trying to do. Consider this example: You can override value & onChange in T and intersect it back with ControlledInput, like here:

import React, { FC } from 'react'


type ControlledInput = {
  value: string;
  onChange: (newValue: string) => void;
}

interface FooProps {
  value: string;
  onChange: (newValue: string) => void;
}

const Foo: FC<FooProps> = (props) => null;

type Keys = 'value' | 'onChange';

const withControl = <T extends ControlledInput>(
  Elem: FC<Omit<T, Keys> & ControlledInput>
) =>
  <Props extends Omit<T, keyof ControlledInput>>(props: Props) => {
    const [value, setValue] = React.useState("");

    return <Elem value={value} onChange={setValue} {...props} />;
  };

withControl(Foo)({ name: 'hellow' }); // ok

This code compiles, but once you add extra property to Foo, it will fail. Because TypeScript checks arguments from left to right. In this case, we ended up with similar error: FooProps type is assignable to ControlledInput but not vice versa because FooProps is a subtype.

If you want to make it work, you should use explicit generic argument. I'm not a huge fan of this technique but is seems that it is the only reasonable way to solve this issue:

type ControlledInput = {
  value: string;
  onChange: (newValue: string) => void;
}

interface FooProps {
  value: string;
  onChange: (newValue: string) => void;
  name: string;
}

const Foo: FC<FooProps> = (props) => null;

type Keys = 'value' | 'onChange';

const withControl = <T extends ControlledInput>(
  Elem: FC<Omit<T, Keys> & ControlledInput>
) =>
  <Props extends Omit<T, keyof ControlledInput>>(props: Props) => {
    const [value, setValue] = React.useState("");

    return <Elem value={value} onChange={setValue} {...props} />;
  };

withControl<FooProps>(Foo)({ name: 'hello' }); // ok
0

I did some experiments, apparently it's a TypeScript's limitation:

interface Foo {
    value: string
    name: string
    num: number
}

function test<T extends Foo>(data: T){

      let {value, ...rest} = data

      let t: T = {value, ...rest} // same error
}

It thinks that {value: string} & Omit<T, 'value'> and T are not the same. And honestly, I don't understand why.

Yet, when dealing with concrete types it works as expected

let foo: Foo = {name: '', value: '', num: 0}

let {num, ...rest} = foo

let foo2: Foo = {num, ...rest} // no error

TS Playground

Type casting it is than.

Alex l.
  • 213
  • 1
  • 14