1

I'm using useReducer to maintain state for an array of object types and I have a few action types. The interfaces looks like:

interface MyObject {
  id: string;
  foo: string;
  bar: string;
}

interface Action {
  type: 'add' | 'remove' | 'update';
  payload?: Partial<MyObject>;
}

The reducer function:

const reducer = (current: MyObject[], action: Action): MyObject[] => {
  switch (action.type) {
    case 'add':
      return [...current, createBaseMyObject()];
    case 'remove':
      return current.filter((item) => item.id !== action.payload?.id);
    case 'update':
      return current.map((item) => {
        if (item.id === action.payload?.id) {
          return { ...item, ...action.payload };
        }
        return item;
      });

    default:
      return current;
  }
};

That works ok but I also need to conditionally pass an array of MyObject as the payload to update in bulk. Tried changing types and function to this:

interface ReducerAction {
  type: 'add' | 'remove' | 'update';
  payload?: Partial<MyObject> | MyObject[];
}

const reducer = (current: MyObject[], action: Action): MyObject[] => {
  switch (action.type) {
    case 'add':
      return [...current, createBaseMyObject()];
    case 'remove':
      // Property 'id' does not exist on type 'MyObject[]'
      return current.filter((item) => item.id !== action.payload?.id);
    case 'update':
      // With proper typing maybe this should be dedicated case
      if (Array.isArray(action.payload)) {
        return action.payload;
      }

      return current.map((item) => {
        // Property 'id' does not exist on type 'MyObject[]'
        if (item.id === action.payload?.id) {
          return { ...item, ...action.payload };
        }
        return item;
      });

    default:
      return current;
  }
};

I'm trying to avoid assertion or extra runtime checks just to satisfy TS. If creating a dedicated case makes it easier that's fine. I'd just want to type it best as possible and ideally keep it simple. Thoughts?

user487869
  • 68
  • 1
  • 6

1 Answers1

1

If you want Typescript to understand that certain types have specific payloads then you need a union type. I would separate the two use cases for 'update'. 'replace' seems like a better name for the array payload since you are replacing the entire state with that array.

type Action = {
  type: 'add';
  // no payload 
} | {
  type: 'remove' | 'update';
  // partial but with id required
  payload: Partial<MyObject> & Pick<MyObject, 'id'>;
} | {
  type: 'replace';
  // array of objects
  payload: MyObject[];
}

const reducer = (current: MyObject[], action: Action): MyObject[] => {
  switch (action.type) {
    case "add":
      return [...current, createBaseMyObject()];
    case "remove":
      return current.filter((item) => item.id !== action.payload.id);
    case "update":
      return current.map((item) => {
        if (item.id === action.payload.id) {
          return { ...item, ...action.payload };
        }
        return item;
      });
    case "replace":
      return action.payload;
    default:
      return current;
  }
};
Linda Paiste
  • 38,446
  • 6
  • 64
  • 102