Reputation: 331
I have a type
type TFormFieldFileProps = {
componentProps: TFileUploaderProps;
select?: never;
checkbox?: never;
file: true;
};
type TFormFieldSelectProps = {
componentProps: TCustomSelectProps;
select: true;
checkbox?: never;
file?: never;
};
type TFormFieldCheckboxProps = {
componentProps: TCustomCheckboxProps;
select?: never;
checkbox: true;
file?: never;
};
type TFormFieldInputProps = {
componentProps: TCustomInputProps;
select?: never;
checkbox?: never;
file?: never;
};
export type TFormFieldProps = { boxProps?: BoxProps } & (
| TFormFieldCheckboxProps
| TFormFieldInputProps
| TFormFieldSelectProps
| TFormFieldFileProps
);
I want to remove componentProps
prop and instead set each type to be an intersection of componentProps
prop type and the other select checkbox file
type.
type TFormFieldFileProps = TFileUploaderProps & {
select?: never;
checkbox?: never;
file: true;
};
type TFormFieldSelectProps = TCustomSelectProps & {
select: true;
checkbox?: never;
file?: never;
};
type TFormFieldCheckboxProps = TCustomCheckboxProps & {
select?: never;
checkbox: true;
file?: never;
};
type TFormFieldInputProps = TCustomInputProps & {
select?: never;
checkbox?: never;
file?: never;
};
export type TFormFieldProps = { boxProps?: BoxProps } & (
| TFormFieldCheckboxProps
| TFormFieldInputProps
| TFormFieldSelectProps
| TFormFieldFileProps
);
But it doesn't work.
const FormField = (props: TFormFieldProps) => {
const { select, checkbox, file, boxProps, ...rest } = props;
return (
<Box
{...boxProps}
sx={{ '& > *': { width: 1 } }}
>
{select ? (
// error: missing some property from TFormFieldCheckboxProps
<CustomSelect {...rest} />
) : checkbox ? (
// error: missing some property from TFormFieldInputProps
<CustomCheckbox {...rest} />
) : file ? (
// error: missing some property from ...
<FileUploader {...rest} />
) : (
// error: missing some property from ...
<CustomInput {...rest} />
)}
</Box>
);
};
I understand why it doesn't work but I don't understand how to solve this problem without having to specify each property on each type...
Can I make it work without writing all the props from all the types in all discriminated union types? If so, how?
Upvotes: 2
Views: 846
Reputation: 26276
Currently when you shatter a type, the extracted variables are no longer associated with each other when parsed by the compiler. Using if
/switch
/etc will no longer change the types of the other variables.
const { select, checkbox, file, ...rest } = props;
/*
select is true | undefined
checkbox is true | undefined
file is true | undefined
rest is { checked: boolean; } | { variant: "filled" | "outlined"; } | { options: string[]; } | { ext: string[]; maxSize: number; }
*/
With the way your types are defined, you would have to use the following logic to type guard in a way the compiler understands:
const FormField = (props: TFormFieldProps) => {
let inputElement;
if (props.select) {
const { select, checkbox, file, options, ...rest } = props;
inputElement = (
<select { ...rest }>
{ options.map(o => (<option value={o}>{o}</option>)) }
</select>
);
} else if (props.checkbox) {
const { select, checkbox, file, ...rest } = props;
inputElement = (<input type="checkbox" {...rest} />);
} else if (props.file) {
const { select, checkbox, file, ...rest } = props;
inputElement = (<input type="file" {...rest} />);
} else {
const { select, checkbox, file, variant, ...rest } = props;
// TODO: do something with variant as its not a valid <input> prop
inputElement = (<input {...rest} />);
}
return inputElement;
}
Although, I would instead use a single type: "select" | "checkbox" | "file" | "custom"
property instead of select
, file
and checkbox
.
// FormField.ts
const FormField = (props: TFormFieldProps) => {
switch (props.type) {
case "select": { // <-- this brace is a container for the below const statement, not part of the switch statement
const { type, options, ...rest } = props;
return (
<select { ...rest }>
{ options.map(val => (<option value={val}>{val}</option>)) }
</select>
);
}
case "checkbox":
return (<input {...props} />);
case "file": { // <-- same with this one
const { ext, ...rest } = props;
return (<input accept={ext.join(",")} {...props} />);
}
default: { // <-- and this one
const { type, variant, ...rest } = props;
// TODO: do something with variant as its not a valid <input> prop
return (<input {...rest} />);
}
}
}
// types.ts
type TFileUploaderProps = {
ext: string[];
maxSize: number;
type: "file"
};
type TCustomSelectProps = {
options: string[];
type: "select"
};
type TCustomCheckboxProps = {
checked: boolean;
type: "checkbox"
};
type TCustomInputProps = {
variant: "filled" | "outlined";
type: "custom"
};
export type TFormFieldProps =
| TCustomCheckboxProps
| TCustomInputProps
| TCustomSelectProps
| TFileUploaderProps;
// Usage:
(<FormField type="checkbox" checked />)
(<FormField type="custom" />)
(<FormField type="file" ext={["png", "jpg", "jpeg"]} maxSize=1024 />)
(<FormField type="select" options={["a", "b", "c"]} />)
Upvotes: 2
Reputation: 329858
For clarity, the issue here is that, while TypeScript 4.6 and above supports control flow analysis on destructured discriminated unions, this does not work for rest properties (as of TypeScript 4.7).
So, this works:
interface Foo { type: "foo"; rest: { x: string } }
interface Bar { type: "bar"; rest: { y: number } }
const process = ({ type, rest }: Foo | Bar) =>
type === "foo" ? rest.x : rest.y; // okay
but this fails:
interface Foo { type: "foo"; x: string }
interface Bar { type: "bar"; y: number }
const process = ({ type, ...rest }: Foo | Bar) =>
type === "foo" ? rest.x : rest.y; // errors
// -------------------> ~ -----> ~
// Property does not exist on {x: string} | {y: number}
There's a recent open request at microsoft/TypeScript#46680 to support this, but it hasn't been implemented yet. You might want to give that issue a 👍 and/or mention your use case (and why it's compelling), but I don't know if it will have any effect.
Upvotes: 3