PYTHON DEVELOPER999
PYTHON DEVELOPER999

Reputation: 331

Typescript discriminated union with intersection

Example sandbox

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

Answers (2)

samthecodingman
samthecodingman

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

jcalz
jcalz

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.

Playground link to code

Upvotes: 3

Related Questions