reactjs - Unión discriminada en Typescript con intersección
Ejemplo de caja de arena
Tengo un tipo
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
);
Quiero eliminar la propiedad componentProps y, en su lugar, establecer cada tipo como una intersección del tipo de propiedad componentProps y el otro tipo select checkbox file.
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
);
Pero no funciona.
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>
);
};
Entiendo por qué no funciona, pero no entiendo cómo resolver este problema sin tener que especificar cada propiedad en cada tipo...
¿Puedo hacerlo funcionar sin escribir todas las propiedades de todos los tipos en todos los tipos de unión discriminada? Si es así, ¿cómo?
Respuesta #1
Para mayor claridad, el problema aquí es que, aunque TypeScript 4.6 y versiones posteriores soportan el análisis de flujo de control en uniones discriminadas desestructuradas, esto no funciona para las propiedades restantes (hasta TypeScript 4.7).
Entonces, esto funciona:
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
pero esto falla:
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}
Hay una solicitud reciente abierta en microsoft/TypeScript#46680 para soportar esto, pero aún no se ha implementado. Podrías dar un 👍 a ese issue y/o mencionar tu caso de uso (y por qué es convincente), pero no sé si tendrá algún efecto.
Enlace de Playground al código
Respuesta #2
Actualmente, cuando rompes un tipo, las variables extraídas ya no están asociadas entre sí cuando son analizadas por el compilador. Usar if/switch/etc ya no cambiará los tipos de las otras 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; }
*/
Con la forma en que se definen tus tipos, tendrías que usar la siguiente lógica para proteger el tipo de una manera que el compilador entienda:
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;
}
Aunque, en su lugar, usaría una sola propiedad type: "select" | "checkbox" | "file" | "custom" en lugar de select, file y 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"]} />)
