TypeScript generics for common reducer typing

Viewed 248

I have a common reducer function to be shared.
I want it to be typed properly. I came up with this:

export interface WithInvalidRows<T extends { [K in keyof T]: InvalidCell[] }> {
  invalidRows: T;
}

interface AddPayload<S extends WithInvalidRows<S['invalidRows']>> {
  cell: InvalidCell;
  screenKey: keyof S['invalidRows'];
}

const addInvalidCell = <S extends WithInvalidRows<S['invalidRows']>>(
  state: S, 
  { cell, screenKey }: AddPayload<S>
) => {
  const rows = state.invalidRows[screenKey];
  const index = R.findIndex((item) => item.id === cell.id, rows);

  ...
};

But I'm getting error Type 'S["invalidRows"]' does not satisfy the constraint '{ [K in keyof S["invalidRows"]]: InvalidCell[]; }'.
Can someone please explain me how S['invalidRows'] which ultimately is { [K in keyof T]: InvalidCell[] } is not assignable there?


Appendix:

With this type

type RowsExtend<T> = Record<keyof T, InvalidCell[]>;
export interface WithInvalidRows<T extends RowsExtend<T> = any> {
  invalidRows: RowsExtend<T>;
}

I now get an error TS2589: Type instantiation is excessively deep and possibly infinite. here:

const reducer = createReducer(
  on(ImportScreenActions.addInvalidEntityRowImportScreen, addInvalidCell),
  ...
);

Types from @ngrx:

export declare type ActionCreator<T extends string = string, C extends Creator = Creator> = C & TypedAction<T>;
export declare type ActionType<A> = A extends ActionCreator<infer T, infer C> ? ReturnType<C> & {
    type: T;
} : never;
export interface OnReducer<S, C extends ActionCreator[]> {
    (state: S, action: ActionType<C[number]>): S;
}
export declare function on<C1 extends ActionCreator, S>(creator1: C1, reducer: OnReducer<S, [C1]>): On<S>;
2 Answers

It's quite difficult to pinpoint why it gives that error. While it was kind of obvious it had to do with the "recursive" generic type, e.g. S extends WithInvalidRows<S['invalidRows']>, getting more details was tricky. I went with the "let me play around with it and try some things out" approach to maybe shed some light.


A first working modification of your code is this:

interface InvalidCell {
    id: number;
}

export interface WithInvalidRows<T extends { [K in keyof T]: InvalidCell[] }> {
    invalidRows: T;
}

interface AddPayload<R extends { [K in keyof R]: InvalidCell[] }> {
    cell: InvalidCell;
    screenKey: keyof R;
}

const addInvalidCell = <
    R extends { [K in keyof R]: InvalidCell[] },
    S extends WithInvalidRows<R>
>(
    state: S,
    { cell, screenKey }: AddPayload<R>
) => {
    const rows = state.invalidRows[screenKey];
    const id: number = rows[0].id;
};

interface MyState extends WithInvalidRows<InvalidRowsScreen> {
    someExtraState: string;
}

interface InvalidRowsScreen {
    foo: InvalidCell[];
    bar: InvalidCell[];
    baz: InvalidCell[];
}

const myState: MyState = null!;
const invalidRows: InvalidRowsScreen = myState.invalidRows;
const someExtraState: string = myState.someExtraState

// Valid
addInvalidCell(myState, {
    cell: { id: 123 },
    screenKey: 'foo',
});
// Error that myState misses the field 'invalid'
addInvalidCell(myState, {
    cell: { id: 123 },
    screenKey: 'invalid',
});

I did have to change what the generic parameter of AddPayload represents and add this same generic parameter to addInvalidCell. TypeScript can still interfere both types, as seen in the example function calls above.


Then after some more fiddling, the error surprisingly disappears when I changed the WithInvalidRows just a bit:

interface InvalidCell {
    id: number;
}

export interface WithInvalidRows<T extends { [K in keyof T]: InvalidCell[] }> {
    invalidRows: { [key in keyof T]: InvalidCell[] }; // originally just `T`
}

interface AddPayload<S extends WithInvalidRows<S['invalidRows']>> {
    cell: InvalidCell;
    screenKey: keyof S['invalidRows'];
}

const addInvalidCell = <S extends WithInvalidRows<S['invalidRows']>>(
    state: S,
    { cell, screenKey }: AddPayload<S>
) => {
    const rows = state.invalidRows[screenKey];
    const id: number = rows[0].id;
};

interface MyState extends WithInvalidRows<InvalidRowsScreen> {
    someExtraState: string;
}

interface InvalidRowsScreen {
    foo: InvalidCell[];
    bar: InvalidCell[];
    baz: InvalidCell[];
}

const myState: MyState = null!;

// Valid
addInvalidCell(myState, {
    cell: { id: 123 },
    screenKey: 'foo',
});
addInvalidCell(myState, {
    cell: { id: 123 },
    screenKey: 'invalid', // Error that 'invalid' is not assignable to `keyof InvalidRowsScreen`
});

I'd be lying if I said that I know exactly why this fixes it. Somehow being extra verbose there makes TypeScript handle the "recursive generic" better.

I'd say it's because S extends WithInvalidRows<S['invalidRows']> tries to read the invalidRows: T field which it tries to read from the (passed) generic parameter which is actually S['invalidRows'], and somehow TypeScript can't compute that (still S-validation) type loop.

By using invalidRows: { [key in keyof T]: InvalidCell[] }, it's more like telling TypeScript "that generic parameter T? can be whatever you want", which allows S to pass the type validation. And since S is valid, keyof T which is keyof S['invalidRows'] can just be calculated fine.

A similar example that stops your error from making S['invalidRows'] error:

interface WithInvalidRowsKeys<K extends keyof any> {
    invalidRows: { [key in K]: InvalidCell[] };
}

export type WithInvalidRows<T extends { [K in keyof T]: InvalidCell[] }> = WithInvalidRowsKeys<keyof T>;

Just like the previous solution, we're not directly making invalidRows: T part of verification of WithInvalidRows. T gets type checked, then keyof T is used to define WithInvalidRows's fields.

But this is all just my theory based on what did and didn't work.

See the closed pull request where typescript team have improved/added some of the features of indexed access type in a number of ways.

Some of the features that I like to mention here which relates to the current behavior.

When an indexed access T[K] occurs on the source side of a type relationship, it resolves to a union type of the properties selected by T[K], but when it occurs on the target side of a type relationship, it now resolves to an intersection type of the properties selected by T[K]. Previously, the target side would resolve to a union type as well, which is unsound.

Given a type variable T with a constraint C, when an indexed access T[K] occurs on the target side of a type relationship, index signatures in C are now ignored. This is because a type argument for T isn't actually required to have an index signature, it is just required to have properties with matching types.

In general, the constraint Record<string, XXX> doesn't actually ensure that an argument has a string index signature, it merely ensures that the properties of the argument are assignable to type XXX.

In your case,

export interface WithInvalidRows<T extends { [K in keyof T]: InvalidCell[] }> {
  invalidRows: T;
}
interface AddPayload<S extends WithInvalidRows<S["invalidRows"]>> {
    cell: InvalidCell;
    screenKey: keyof S["invalidRows"];
}

S["invalidRows"] takes the type of invalidRows key of generic T,

S['invalidRows'] = T

Here we didn't know the index signature of T so S['invalidRows'] is not able to understand that it is an index type.

So, the only solution that I can think of is to give the index signature of generic T. Since it is an interface we cannot do typecast so, therefore, we have to give the type of invalidRows key same as constraint type.

type RowsExtend<T> = Record<keyof T, InvalidCell[]>;
export interface WithInvalidRows<T extends RowsExtend<T>> {
    invalidRows: RowsExtend<T>;
}

Full code with examples:

export interface InvalidCell {
    id: string;
    severity?: string;
}

type RowsExtend<T> = Record<keyof T, InvalidCell[]>;
export interface WithInvalidRows<T extends RowsExtend<T>> {
    invalidRows: RowsExtend<T>;
}

interface AddPayload<S extends WithInvalidRows<S["invalidRows"]>> {
    cell: InvalidCell;
    screenKey: keyof S["invalidRows"];
}

interface MyState extends WithInvalidRows<InvalidRowsScreen> {
    // more states
}

interface InvalidRowsScreen {
    foo: InvalidCell[];
    bar: InvalidCell[];
    baz: InvalidCell[];
}
const addInvalidCell = <S extends WithInvalidRows<S["invalidRows"]>>(
    state: S,
    { cell, screenKey }: AddPayload<S>
) => {
    const rows = state.invalidRows[screenKey];
    const index = rows.findIndex(item => item.id === cell.id, rows);
};

const someState: MyState = {
    invalidRows: {
        foo: [{ id: "22" }],
        bar: [{ id: "123" }],
        baz: [{ id: "634" }],
    },
    // some more states
};

addInvalidCell(someState, {
    cell: { id: "123" },
    screenKey: "foo",
});

addInvalidCell(someState, {
    cell: { id: "123" },
    screenKey: "invalid",
});
Related