Search code examples
typescripttypeguards

Narrowing TypeScript disjoint union with literal type guard works in function, but not on React state


I have a reducer with a nextAction state that works as sort of a callback. The NextAction is typed like so:

type NextActionSave = {type: 'save'};
type NextActionSaveAndSet = {type: 'saveAndSet', name: TextFieldName, value: string};
type NextAction = NextActionSave | NextActionSaveAndSet;
interface EditRowState {
  ...
  nextAction: NextAction | null;
}

In another file, I have a switch statement on state.nextAction.type

import React from 'react';
import {EditRowState, EditRowAction, NextAction} from './reducer';

interface Props {
  save: () => Promise<void>;
  state: EditRowState;
  dispatch: React.Dispatch<EditRowAction>;
}

const useNextAction = (props: Props) => {
  React.useEffect(() => {
    executeNextAction(props.state.nextAction);
  }, [props.state.nextAction]);

  const executeNextAction = (nextAction: NextAction | null) => {
    if (nextAction === null) return;

    switch(nextAction.type) {
      case 'save':
        props.save();
        break;
      case 'saveAndSet':
        props.save().then(() => {
          const {name, value} = nextAction;
          props.dispatch({type: 'set', name, value, advance: true});
        });
      default: return;
    }
  };
};

This works. Here is a TypeScript Playground of the executeNextAction showing the executeNextAction function.

If I move the body of executeNextAction inside the useEffect, so that nextAction comes directly from the state, it says, "Property 'name' does not exist on type 'NextAction'". I get the same error for the value property. My code without a separate executeNextAction function is below. Here is a simplified version of my code below in a TypeScript Playground showing the error.

import React from 'react';
import {EditRowState, EditRowAction} from './reducer';

interface Props {
  save: () => Promise<void>;
  state: EditRowState;
  dispatch: React.Dispatch<EditRowAction>;
}

const useNextAction = (props: Props) => {
  React.useEffect(() => {
    if (props.state.nextAction === null) return;

    switch(props.state.nextAction.type) {
      case 'save':
        props.save();
        break;
      case 'saveAndSet':
        props.save().then(() => {
          const {name, value} = props.state.nextAction;
          props.dispatch({type: 'set', name, value, advance: true});
        });
      default: return;
    }
  }, [props.state.nextAction]);
};

It works if I typecast props.state.nextAction using as NextActionSaveAndSet. It looks like the switch statement acts as a literal type guard, so I shouldn't need the type cast. I'm guessing this is why it works in the version where I moved the code into a executeNextAction function. What's the difference between using nextAction as a function parameter or accessing it on the state? Would I be able to use it on the state if I added an as const somewhere?


Solution

  • I am not able to properly explain why this works, but I have two different solutions which don't involve assertion.

    1. Save this.props.nextAction to a variable.
    const useNextAction = (props: Props) => {
      React.useEffect(() => {
        const {nextAction} = props.state;
        if (nextAction === null) return;
    
        switch(nextAction.type) {
          case 'save':
            props.save();
            break;
          case 'saveAndSet':
            props.save().then(() => {
              const {name, value} = nextAction;
              props.dispatch({type: 'set', name, value, advance: true});
            });
          default: return;
        }
      }, [props.state.nextAction]);
    };
    
    1. Destructure name and value outside of the callback
    const useNextAction = (props: Props) => {
      React.useEffect(() => {
        if (props.state.nextAction === null) return;
    
        switch(props.state.nextAction.type) {
          case 'save':
            props.save();
            break;
          case 'saveAndSet':
            const {name, value} = props.state.nextAction;
            props.save().then(() => {
              props.dispatch({type: 'set', name, value, advance: true});
            });
          default: return;
        }
      }, [props.state.nextAction]);
    };
    

    Typescript Playground Link