skip to Main Content

I’ve created a functional component that looks like this:

type RootComponentProps = {
  propThatAlwaysExists: string;
};

type ComponentPropsV1 = {
  type: "v1";
  onlyOnV1: string;
  onlyOnV2?: never;
};

type ComponentPropsV2 = {
  type: "v2";
  onlyOnV1?: never;
  onlyOnV2: string;
};

type ComponentProps = RootComponentProps & (
  | ComponentPropsV1
  | ComponentPropsV2
);

export const Component: FC<ComponentProps> = ({
  propThatAlwaysExists,
  type,
  onlyOnV1,
  onlyOnV2,
}) => {
  // do typechecked things, no errors in component body
};

The prop?: never type is a hack I’d learned to be allowed to destructure props, as TypeScript forbids direct access of any property not definitely guaranteed to exist, but destructuring is the preferred prop access method for code correctness tools like eslint, and in my opinion, code readability in general.

I’m then trying to mount this component in its parent, which passes most of these props through. I’d originally tried writing out the parent explicitly:

import { Component } from "./Component";

type RootParentComponentProps = {
  label: string;
  propThatAlwaysExists: string;
};

type ParentComponentPropsV1 = {
  type: "v1";
  onlyOnV1: string;
  onlyOnV2?: never;
};

type ParentComponentPropsV2 = {
  type: "v2";
  onlyOnV1?: never;
  onlyOnV2: string;
};

type ParentComponentProps = RootParentComponentProps & (
  | ParentComponentPropsV1
  | ParentComponentPropsV2
);

export const ParentComponent: FC<ParentComponentProps> = ({
  label,
  type,
  onlyOnV1,
  onlyOnV2,
}) => {
  // some effects, yadda yadda

  return (
    <div>
      <span>{label}</span>
      <Component
        propThatAlwaysExists={propThatAlwaysExists}
        type={type}
        onlyOnV1={onlyOnV1}
        onlyOnV2={onlyOnV2}
      />
    </div>
  );
};

But I got a very strange error:

Types of property 'onlyOnV1' are incompatible.
  Type 'string | undefined' is not assignable to type 'undefined'.
    Type 'string' is not assignable to type 'undefined'.ts(2322)

I thought this might be because TS was unable to be certain of the identical exclusions between the types, and realized I could DRY up my code some, so I changed ParentComponent‘s type definitions to this:

import { Component, ComponentProps } from "./Component";

type RootParentComponentProps = {
  label: string;
};

type ParentComponentProps = RootParentComponentProps & ComponentProps;

However, the error remained unchanged.
At this point, I thought the problem might be that TypeScript effectively loses the context of the exclusivity of the properties, and/or that the strictness of never really means never, so I’ve created an impossible arrangement of props by specifying both onlyOnV1 and onlyOnV2 when the union type I’ve created effectively says that only one of those properties can ever exist.
To combat this, I tried changing the exclusive types of Component from

type ComponentPropsV1 = {
  type: "v1";
  onlyOnV1: string;
  onlyOnV2?: never;
};

type ComponentPropsV2 = {
  type: "v2";
  onlyOnV1?: never;
  onlyOnV2: string;
};

to

type ComponentPropsV1 = {
  type: "v1";
  onlyOnV1: string;
  onlyOnV2: undefined;
};

type ComponentPropsV2 = {
  type: "v2";
  onlyOnV1: undefined;
  onlyOnV2: string;
};

but again the error remained unchanged, and this is where it stops making sense for me. As far as I can tell, these things are true:

  • TypeScript knows from the type of ComponentProps that onlyOnV1 and onlyOnV2 have inversely exclusive types, where if one is a string the other must be undefined and vice-versa
  • The type passed through from ParentComponent to Component is exactly the type of ComponentProps

Yet somehow, TypeScript has invented a scenario where the exclusive union type broadens from

{
  type: "v1";
  onlyOnV1: string;
  onlyOnV2: undefined;
} | {
  type: "v2";
  onlyOnV1: undefined;
  onlyOnV2: string;
}

to

{
  type: "v1" | "v2";
  onlyOnV1: string | undefined;
  onlyOnV2: string | undefined;
}

for the purposes of type checking against ComponentProps even though it literally is ComponentProps.

Am I using and/or designing my union types incorrectly?
Is there any way for me to make this work without sacrificing the explicitness of Component or ParentComponent?

2

Answers


  1. Chosen as BEST ANSWER

    After attempting wonderflame's answer without success, I realized that my "minimum reproducible example" was in fact oversimplified - not pictured here was an interstitial use of the TypeScript builtin Omit<T, U> type, which allows you to strike keys from an object.

    Omit had been responsible for the type-broadening because it seems that composite types get combined together when simple object mapping types are run over them. To fix this, I had to be more explicit about my Component props as they're passed through from the parent, and spread the props intended for Component, changing this -

    type ComponentPassthroughProps = Omit<ComponentProps, "strickenKey">
    

    to this

    type ComponentPassthroughProps = RootComponentProps & Omit<
      | ComponentPropsV1
      | ComponentPropsV2,
      "strickenKey"
    >
    

  2. Typescript does what is expected; since you destruct the props, type can be v1 or v2; that’s why you get the error. There are two options:
    Option 1, don’t destruct props and pass the whole object:

    <Component {...props} />
    

    Option 2, check the value of type explicitly:

    {type === 'v1' ? (
            <Component
              propThatAlwaysExists={'propThatAlwaysExists'}
              type={type}
              onlyOnV1={onlyOnV1}
              onlyOnV2={onlyOnV2}
            />
          ) : type === 'v2' ? (
            <Component
              propThatAlwaysExists={'propThatAlwaysExists'}
              type={type}
              onlyOnV1={onlyOnV1}
              onlyOnV2={onlyOnV2}
            />
          ) : null}
    

    playground

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search