'How to write a generic type that includes all props from one generic type that are not in another?

I'm trying to write a generic HOC that takes a component with props TProps and a type TEntry defining an object structure. I want the HOC to return a component which will call a hook to get an object instance of type TEntry from a particular source (in this case a CMS). This resulting component should only require the consumer to provide the props that are not filled in by that CMS data.

I thought that Exclude<TProps, TEntry> would work for this, but I'm still ending up with a component that requires all of TProps. Here's what I've tried (this is rather stripped down for simplicity):

const withCmsData = <
  TEntry,
  TProps,
>(
  Component: (props: TProps) => (JSX.Element | null),
  fallback: TEntry,
) => {

  return (props: Exclude<TProps, TEntry>) => {
    const { entry } = useCmsEntries<TEntry>({ fallback });
    return <Component {...entry} {...props} />;
  };
};

...and here's an example of how you'd use it to create a new component with fewer props:

type ArticlePageProps = {
  title: string;
  subTitle: string;
  date: string;
}
const ArticlePage = ({ title, subTitle, date }: ArticlePageProps) => {
  return (
    <>
      <h1>{title}</h1>
      <div>{subTitle}</div>
      <div>{date}</div>
    </>
  );
}

type ArticleFromCmsProps = {
  title: string;
  subTitle: string;
}
/*
This creates a new component that now only requires a data prop,
which is the one prop from ArticlePageProps that is
not provided by ArticleFromCmsProps:
*/
const fallback: ArticleFromCmsProps = getFallback();
const MyPageWrapper = withCmsData<ArticlePageProps, ArticleFromCmsProps>(ArticlePage, fallback); 

const render = () => {

  const data = new Date();
  <MyPageWrapper date={date} />
}

In the above example, I expect to get a component that requires only the date prop, but instead, at design-time in VSCode I'm getting warnings that the component still requires all 3 props from ArticlePageProps -- <MyPageWrapper date={date} /> is showing up with the error message that is is missing the title and subtitle properties

Is there a way to get the generic HOC I want? One that returns a component only requiring props not specified by the TEntry type parameter?



Solution 1:[1]

I've included some type utilities to reduce repetition, but the core idea is that you accept the fallback object and the component that you desire to render, inferring their types using generics to create the abstracted component signature which uses the utility Omit<Type, Keys> to create the props.

Due to issues in the way TS deals with function parameter bivariance, the Props type of FunctionComponent needs to be fairly permissive in order to avoid needing to overwrite component and prop types using assertions in the HOF body.

TS Playground

import {
  default as React,
  type ReactElement,
} from 'react';

// You didn't show this type in your question, so I made a best-effort guess:
declare function useCmsEntries <T>(param: { fallback: T; }): { entry: T };

type FunctionComponent<Props extends object = any> = (props: Props) => ReactElement | null;
type ComponentProps<Component extends FunctionComponent> = Parameters<Component>[0];

type ComponentWithExcludedProps<
  Props extends object,
  Component extends FunctionComponent<Props>,
> = FunctionComponent<Omit<ComponentProps<Component>, keyof Props>>;

type ArticlePageProps = {
  date: string;
  subTitle: string;
  title: string;
};

function createComponentWithoutFallbackProps<
  IncludedProps extends object,
  C extends FunctionComponent,
>(
  fallback: IncludedProps,
  Component: C,
): ComponentWithExcludedProps<IncludedProps, C> {
  return (props) => {
    const { entry } = useCmsEntries<IncludedProps>({ fallback });
    // Make sure you spread these in the desired order: props in the latter will overwrite the former
    const all: ComponentProps<C> = {...entry, ...props};
    return <Component {...all} />;
  };
}

declare const ArticlePage: (props: ArticlePageProps) => ReactElement;
type ArticleFromCmsProps = Pick<ArticlePageProps, 'subTitle' | 'title'>;
declare const fallback: ArticleFromCmsProps;

const MyPageWrapper = createComponentWithoutFallbackProps(fallback, ArticlePage);
type MyPageWrapperProps = Parameters<typeof MyPageWrapper>[0]; // { date: string; } ?

const render = () => {
  const date = new Date().toISOString();
  <MyPageWrapper date={date} /> // ok
};

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1