typescriptgenericsreducers

How to Make a Typescript Reducer with Exact Generic Types


Problem

I have two reducers that use the exact same logic for two different arrays containing points. An array will only have points of a single type; these types are A and B. I want to create a reducer that accepts an array of points of type A or type B and then returns an array of that same type.

When I try to compile the code below, I get this error:

Argument of type 'Partial<A> | Partial<B>' is not assignable to parameter of type 'Partial<T>'.
  Type 'Partial<A>' is not assignable to type 'Partial<T>'.

How can I fix this?

Problematic Code

enum Category {
  A,
  B
}

interface A  {
  readonly category: Category.A;
}
interface B  {
  readonly category: Category.B;
}

Category = 

const genericReducer = <T extends A | B>(
  state: MyState,
  action: Actions,
  points: T[],
  category: Category
): T[] => {
  switch (action.type) {
    case POINT_UPDATED: {
      if (action.payload.category !== category) return points;

      return updateItemInArray(points, action.payload.id, (stpt) => {
        return updateObject(stpt, action.payload.newValues);
      });
    }

    default:
      return points;
  }
};

ArrayUtils.updateObject

For reference, here is the updateObject function:

static updateObject = <T>(oldObject: T, newValues: Partial<T>) => {
    // Encapsulate the idea of passing a new object as the first parameter
    // to Object.assign to ensure we correctly copy data instead of mutating
    return Object.assign({}, oldObject, newValues);
};

updateItemInArray Function

static updateItemInArray = <T>(array: T[], itemId: string, updateItemCallback: (item: T) => T): T[] => {
    const updatedItems = array.map((item) => {
      if (item.id !== itemId) {
        // Since we only want to update one item, preserve all others as they are now
        return item;
      }

      // Use the provided callback to create an updated item
      const updatedItem = updateItemCallback(item);
      return updatedItem;
    });

    return updatedItems;
};

EDIT #1

Here is a CodeSandbox link to my latest attempt based on Linda Paiste's answer. At this time there is still an issue assigning to type Partial<T>.

CodeSandbox Link


Solution

  • I started trying to fill in the missing pieces of your code in order to find where the problem is. Doing that, it became apparent that the issue is in your Action type (as suggested by @Alex Chashin).

    I know that Actions has a type which needs to include POINT_UPDATED. It also has a payload. The payload includes an id, a category, and some newValues.

    type Actions = {
        type: typeof POINT_UPDATED;
        payload: {
            id: string;
            category: Category;
            newValues: ????;
        }
    }
    

    What is newValues? Based on the signature of updateObject, we know that it should be Partial<T>. But Actions doesn't know what T is.

    I'm guessing that your code uses something like newValues: A | B;, which gives me the error that you posted. This is not specific enough. It's not enough to know that our new values are "A or B". updateObject says that if T is A then newValues must be A and if T is B then newValues must be B.

    Therefore your Actions needs to be a generic type.

    type Actions<T> = {
        type: typeof POINT_UPDATED;
        payload: {
            id: string;
            category: Category;
            newValues: Partial<T>;
        }
    }
    
    const genericReducer = <T extends A | B>(
      state: MyState,
      action: Actions<T>,
      points: T[],
      category: Category
    ): T[]
    ...
    

    I'm seeing an error on your updateItemInArray when trying to access item.id:

    Property 'id' does not exist on type 'T'

    You need to refine the type of T such it knows about the id property:

    const updateItemInArray = <T extends {id: string}>(
    

    Doing this causes new errors in your genericReducer because you have said that T must have a category but you haven't said that it must have an id. We can fix that with:

    const genericReducer = <T extends (A | B) & {id: string}>(
    

    which is the same as

    const genericReducer = <T extends {id: string; category: Category}>(
    

    Though it actually appears that you are never looking at the category on your points T[]. So all you really need is:

    const genericReducer = <T extends {id: string}>(
    

    TypeScript Playground Link


    Edit:

    The error that you are getting in your revised code is honestly really dumb. Technically T extends a type with {elevation: number}. So there is the potential that it could require a more specific version of the type, like {elevation: 99}. In that particular case, {elevation: number} would not be assignable to Partial<{elevation: 99}>.

    At least I thought that was the problem. However my first fix did not work. I tried to refine the type of Actions to say that the elevation property must match the one from T.

    type Payload<T extends A_or_B> = {
      [ActionTypes.FETCH_SUCCEEDED]: {
        id: string;
        elevation: T['elevation'];
        timestamp: number;
      };
    ...
    

    But I'm still getting an error, so now I'm thoroughly confused.

    Argument of type { elevation: T["elevation"]; } is not assignable to parameter of type Partial<T>

    I really can't explain that one.

    You might have to make an assertion instead. (Don't bother with the fix above).

    return updateObject<{elevation: number}>(stpt, {
       elevation: action.payload.elevation,
    }) as T;
    

    We avoid errors within the updateObject function by setting the generic T of that function to {elevation: number}. Both stpt (type T) and {elevation: number} are assignable to that type.

    But now the return type of the updateObject function is {elevation: number} instead of T. So we need to assert as T in order to change the type.