'Typescript conditional return type based on props

  1. In electron application I have a component that renders a button and sends a message to an arbitrary channel to the main process.
  2. The main process does some work and returns back the result, its return type is depended on the channel name.
  3. Parent component also passes a callback along with channel name.
const Button = ({ name, channel, callback }: ButtonProps) => {
  const [isDisabled, setDisabled] = useState<boolean>(false);

  const onClickHandler = () => {
    setDisabled(true);
    window.electron.ipcRenderer.sendMessage(channel);
  };

  useEffect(() => {
    // type definition for the function below:
    // on<T>(channel: string, func: (result: T) => void): () => void;
    window.electron.ipcRenderer.on<ReturnType>(channel, (result) => {
      setDisabled(false);
      callback(result);
    });

    return () => {
      window.electron.ipcRenderer.removeAllListeners(channel);
    };
  }, [channel, callback]);

  return <button 
           type="button" 
           onClick={onClickHandler} 
           disabled={isDisabled}
         >
           {name}
         </button>;
};

export default Button;

How do I specify a type of a result that is being passed to a callback?

I started with something like this:

type ReturnType = OpenDialogReturnValue | SaveDialogReturnValue;

type ButtonProps = {
  name: string;
} & (
  | {
      channel: "OPEN_DIALOG";
      callback: (result: OpenDialogReturnValue) => void;
    }
  | {
      channel: "SAVE_EXAMPLE";
      callback: (result: SaveDialogReturnValue) => void;
    }
);

But it throws type error on the line with callback(result);

Argument of type 'ReturnType' is not assignable to parameter of type 'OpenDialogReturnValue & SaveDialogReturnValue'.

What is wrong here?



Solution 1:[1]

There are a number of ways you can tackle this issue.

The core problem

The reason why you are seeing that error is because even though you had separated the differentiating values for channel and callback properties into two respectively different objects, channel and callback types are still undetermined when they are within the useEffect function. This means that TypeScript does not know what parameter type the callback should have and will automatically request an intersection of both OpenDialogReturnValue and SaveDialogReturnValue types just to be sure that whatever the callback is in runtime - it will definitely get an object argument that it can use.

Solutions

  1. The simplest way to fix this is to lend TypeScript a hand and tell it to trust us by using type casting. As we already know, TypeScript is asking us to provide an argument for callback with an intersection type as it is not sure which callback function will exist in runtime ((result: OpenDialogReturnValue) => void) | (result: SaveDialogReturnValue) => void)). We can instead tell TypeScript that there is only one callback and it is the argument types that vary (results: OpenDialogReturnValue | SaveDialogReturnValue) => void. This can be done by first extracting the possible parameter types that callback can have by using a Utility type called Parameters and then casting its first parameter over callback to assign a new variable called typedCallback:
useEffect(() => {
  type CallbackParameterTypes = Parameters<typeof callback>;
  const typedCallback = callback as (results: CallbackParameterTypes[0]) => void;
  window.electron.ipcRenderer.on<CallbackParameterTypes[0]>(channel, (result) => {
    setDisabled(false);
    typedCallback(result);
  });

Playground Link.

  1. If the association between channel and callback parameter types do not matter, then the above can be simplified into the following:
type ButtonProps = {
  name: string;
  channel: "OPEN_DIALOG" | "SAVE_EXAMPLE";
  callback: (result: OpenDialogReturnValue | SaveDialogReturnValue) => void;
};
useEffect(() => {
  window.electron.ipcRenderer.on<Parameters<typeof callback>[0]>(channel, (result) => {
    setDisabled(false);
    callback(result);
  });

Playground Link.

  1. Another way you can approach the problem is by using type predicates which can be difficult to maintain, however they let TypeScript know exactly what type is being used when. To apply them, you would first need to refactor the union objects into separate interfaces; e.g. OpenDialogObject and SaveExampleObject. You can then use an is type predicate to tell TypeScript which particular interface does the current props object adhere to as done in the isOpenDialog function. The function itself uses the channel property to decide which type the object is. This can then be used within useEffect to help determine what parameter type props.callback is going to have during runtime. However as you can observe - the syntax can get a little bit repetitive, especially if you will have more types. For completeness; the type predicate function will only produce wanted results when passing and inferring the full props object and not individual channel and callback properties. The reason for that is because these property types are not enough to determine what type the overall object will be:
interface OpenDialogObject {
  channel: "OPEN_DIALOG";
  callback: (result: OpenDialogReturnValue) => void;
}

interface SaveExampleObject {
  channel: "SAVE_EXAMPLE";
  callback: (result: SaveDialogReturnValue) => void;
}

type ButtonProps = {name: string;} & (OpenDialogObject | SaveExampleObject);
if (isOpenDialog(props)) {
  window.electron.ipcRenderer.on<Parameters<typeof callback>[0]>(channel, (result) => {
    setDisabled(false);
    callback(result);
  });
} else {
  window.electron.ipcRenderer.on<Parameters<typeof callback>[0]>(channel, (result) => {
    setDisabled(false);
    callback(result);
  });
}

Playground Link.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1