phen0menon
phen0menon

Reputation: 2452

React: Inject props through HOC without declaring types for them

I'm trying to do something kinda connect() in react-redux bindings. Here's my HOC that injects props in a component:

export function withAdditionalProps<T, I>(
  injectedProps: I,
  WrappedComponent: React.ComponentType<T>,
): React.ComponentType<Omit<T, keyof I>> { ... }

It works okay if I declare injected props types (I generic type), but what If I want to do a HOC without declaring these types (omit injected props keys on-fly). How can I determine keys from the passed props? I tried, for example, something like this:

export function withAdditionalProps<T>(
  injectedProps: { [key: string]: unknown },
  WrappedComponent: React.ComponentType<T>,
): React.ComponentType<Omit<T, keyof typeof injectedProps>> { ... }

const InjectedComponent = withAdditionalProps<AppState>(
  {
     counter: 0
  },
  (props) => (<div>{props.counter}</div>)
);

But it's not working correct: compiler throws an error when rendering the component. Look at the screenshot (testProp is a "native" prop of a component) Problem Maybe anyone can help me.

Upvotes: 8

Views: 1107

Answers (2)

danvk
danvk

Reputation: 16945

If you capture both the component's Props type and the injected props type in generic parameters, you can make this work:

export function withAdditionalProps<C extends unknown, I extends object>(
  injectedProps: I,
  WrappedComponent: React.ComponentType<C>,
): React.ComponentType<Omit<C, keyof I>> {
  // ...
}

Rather than passing AppState as a generic parameter, make sure TypeScript has enough context to infer it. You can do this by declaring AppState in the wrapped component:

interface AppState {
  counter: number;
}

const InjectedComponent = withAdditionalProps(
  {
    counter: 0,
  },
  (props: AppState) => <div>{props.counter}</div>,
);

const el = <InjectedComponent />;  // OK

const InjectedComponent2 = withAdditionalProps(
  {
    counter2: 0,
  },
  (props: AppState) => <div>{props.counter}</div>,
);

const el2 = <InjectedComponent2 />; // error: Property 'counter' is missing

TypeScript won't let you specify some generic parameters but leave others inferred. If you want to pass AppState via a generic, the standard workaround is to use two functions, one with the explicit generic parameters and one with the inferred ones. Here's how that looks in your example:

export function withAdditionalProps<PropsType>(): <I extends unknown>(
  injectedProps: I,
  WrappedComponent: React.ComponentType<PropsType>,
) => React.ComponentType<Omit<PropsType, keyof I>> {
  // ...
}

And here's how you use it:

interface AppState {
  counter: number;
}
const InjectedComponent = withAdditionalProps<AppState>()({
    counter: 0,
  },
  props => <div>{props.counter}</div>,
);

const el = <InjectedComponent />;  // OK

const InjectedComponent2 = withAdditionalProps<AppState>()(
  {
    counter2: 0,
  },
  props => <div>{props.counter}</div>,
);

const el2 = <InjectedComponent2 />; // error: Property 'counter' is missing

I can't quite make out what's going on behind the error message in your screenshot. If you can share the text or a link, I can take a look.

Upvotes: 0

davnicwil
davnicwil

Reputation: 30967

In short, your second example isn't possible yet in Typescript.

The issue is that the { [key:string]: unknown } type always captures all possible strings as keys, rather than narrowing to the concrete ones you use in a particular call, which would make usage of Omit here possible.

As it is, Omit used with { [key:string]: unknown } simply omits all possible keys and therefore all your native props in T. This may be possible in future via the negated types feature, but for now the only way is via declared type variables as in your first example.

However, those declared type variables in the function definition don't oblige you to also declare them for each call. See the code below - the compiler will infer both T and I from the concrete arguments you pass when you call the function. So in practice the only difference here is in the type definitions for your HOC, and honestly it seems like similar effort/readability either way.

function foo<T, I>(t: T, i: I): Omit<T, keyof I> { 
    return { ...t, ...i }
}

// notice explicitly declaring foo<{ a: number, b: number}, { c: number }> 
// is NOT necessary. The correct types are just inferred from the args you pass.
const bar = foo({ a: 1, b: 2 }, { b: 3 })

bar.a // OK
bar.b // ERROR, since b is omitted

Upvotes: 2

Related Questions