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
thatonlyOnV1
andonlyOnV2
have inversely exclusive types, where if one is astring
the other must beundefined
and vice-versa - The type passed through from
ParentComponent
toComponent
is exactly the type ofComponentProps
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
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 myComponent
props as they're passed through from the parent, and spread the props intended for Component, changing this -to this
Typescript does what is expected; since you destruct the props,
type
can bev1
orv2
; that’s why you get the error. There are two options:Option 1, don’t destruct
props
and pass the whole object:Option 2, check the value of
type
explicitly:playground