Repository: ctrlplusb/easy-peasy (js)

Issue:
Can't use index signature types in model with generics
Related issue: https://github.com/ctrlplusb/easy-peasy/issues/117

I'm using `"easy-peasy": "2.3.0"` and `"typescript": "3.3.4000"`.

I have the following store, which produces a TS "error":
```ts
interface IMyStoreModel<T extends IMyInterface> {
  myMap: {
    [id: string]: T;
  };
  values: Select<IMyStoreModel<T>, Array<T>>;
}

const generateModel = <T extends IMyInterface>() => {
  const myStore: IMyStoreModel<T> = {
    myMap: {},
    values: select(state => 
      Object.keys(state.map)
        //                  👇 error
        .map(key => state.myMap[key])),
  }
  return myStore;
}
```

If I define `IMyStoreModel` without using generic types, the error disappears:
```ts
// works
interface IMyStoreModel {
  myMap: {
    [id: string]: IMyInterface;
  };
  values: Select<IMyStoreModel, Array<IMyInterface>>;
} 
```

I could also cast `state.myMap` to its correct type - but that gets quite ugly.

Here is the exact error message produced:
> (property) myMap: Compact<{ [K in (T extends Select<any, any> | Reducer<any, any> ? string : never) | (T extends Select<any, any> | Reducer<any, any> ? number : never) | Exclude<Exclude<...>, (T extends Select<...> | Reducer<...> ? string : never) | (T extends Select<...> | Reducer<...> ? number : never)> | Exclude<...>]: { [P in keyof Compact<...>]: Compact<...>[P]; }[K]; } & Compact<...>>
Element implicitly has an 'any' type because type 'Compact<{ [K in (T extends Select<any, any> | Reducer<any, any> ? string : never) | (T extends Select<any, any> | Reducer<any, any> ? number : never) | Exclude<Exclude<...>, (T extends Select<...> | Reducer<...> ? string : never) | (T extends Select<...> | Reducer<...> ? number : neve...' has no index signature.

Pull request description:
Fixes support for index signatures on model state in Typescript
Closes #189 

Published as `2.3.1-alpha.1`

Patch:
diff --git a/index.d.ts b/index.d.ts
index ee81710..22d30e4 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -2,6 +2,7 @@
 
 import { Component } from 'react';
 import {
+  Diff,
   KeysOfType,
   Omit,
   OptionalKeys,
@@ -21,6 +22,21 @@ import {
 } from 'redux';
 import { string } from 'prop-types';
 
+/**
+ * This allows you to narrow keys of an object type that are index signature
+ * based.
+ *
+ * Based on answer from here:
+ * https://stackoverflow.com/questions/56422807/narrowing-a-type-to-its-properties-that-are-index-signatures/56423972#56423972
+ */
+type IndexSignatureKeysOfType<A extends Object> = {
+  [K in keyof A]: A[K] extends { [key: string]: any }
+    ? string extends keyof A[K]
+      ? K
+      : never
+    : never
+}[keyof A];
+
 type ActionTypes = Action<any, any> | Thunk<any, any, any, any, any>;
 
 type Meta = {
@@ -86,6 +102,19 @@ export type Actions<Model extends Object> = {
 type RequiredOnly<Model extends Object> = Pick<Model, RequiredKeys<Model>>;
 type OptionalOnly<Model extends Object> = Pick<Model, OptionalKeys<Model>>;
 
+type StateModelValues<
+  Model extends Object,
+  P extends keyof Model
+> = Model[P] extends Select<any, infer R>
+  ? R
+  : Model[P] extends Reducer<infer R, any>
+  ? R
+  : Model[P] extends object
+  ? Model[P] extends Array<any> | RegExp | Date | Function
+    ? Model[P]
+    : State<Model[P]>
+  : Model[P];
+
 type RecursiveState<
   OtherModel extends Object,
   RequiredModel extends Object,
@@ -93,31 +122,21 @@ type RecursiveState<
 > = Overwrite<
   { [P in keyof OtherModel]: OtherModel[P] },
   Overwrite<
-    {
-      [P in keyof RequiredModel]: RequiredModel[P] extends Select<any, infer R>
-        ? R
-        : RequiredModel[P] extends Reducer<infer R, any>
-        ? R
-        : RequiredModel[P] extends object
-        ? RequiredModel[P] extends Array<any> | RegExp | Date
-          ? RequiredModel[P]
-          : State<RequiredModel[P]>
-        : RequiredModel[P]
-    },
-    {
-      [P in keyof OptionalModel]?: OptionalModel[P] extends Select<any, infer R>
-        ? R
-        : OptionalModel[P] extends Reducer<infer R, any>
-        ? R
-        : OptionalModel[P] extends object
-        ? OptionalModel[P] extends Array<any> | RegExp | Date
-          ? OptionalModel[P]
-          : State<OptionalModel[P]>
-        : OptionalModel[P]
-    }
+    { [P in keyof RequiredModel]: StateModelValues<RequiredModel, P> },
+    { [P in keyof OptionalModel]?: StateModelValues<OptionalModel, P> }
   >
 >;
 
+type FilterIndexSignatures<
+  StateModel extends Object,
+  RequiredStateModel extends Object,
+  OptionalStateModel extends Object
+> = RecursiveState<
+  StateModel,
+  Omit<RequiredStateModel, keyof StateModel>,
+  Omit<OptionalStateModel, keyof StateModel>
+>;
+
 /**
  * Filters a model into a type that represents the state only (i.e. no actions)
  *
@@ -125,8 +144,8 @@ type RecursiveState<
  *
  * type StateOnly = State<Model>;
  */
-export type State<Model extends Object> = RecursiveState<
-  FilterStateTypes<Omit<Model, RequiredKeys<Model> & OptionalKeys<Model>>>,
+export type State<Model extends Object> = FilterIndexSignatures<
+  Pick<Model, IndexSignatureKeysOfType<Model>>,
   FilterStateTypes<RequiredOnly<Model>>,
   FilterStateTypes<OptionalOnly<Model>>
 >;
diff --git a/package.json b/package.json
index 9b3e48c..8d9b685 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "easy-peasy",
-  "version": "2.3.0",
+  "version": "2.3.1-alpha.3",
   "description": "Easy peasy global state for React",
   "license": "MIT",
   "main": "dist/easy-peasy.cjs.js",


Test patch:
diff --git a/src/__tests__/typescript/issue189.ts b/src/__tests__/typescript/issue189.ts
new file mode 100644
index 0000000..1392691
--- /dev/null
+++ b/src/__tests__/typescript/issue189.ts
@@ -0,0 +1,44 @@
+/* eslint-disable */
+
+import { createStore, select, Select, action, Action } from 'easy-peasy';
+
+interface IMyInterface {
+  name: string;
+}
+
+interface Person extends IMyInterface {}
+
+interface IMyStoreModel<T extends IMyInterface> {
+  myMap: Record<string, T>;
+  setMyMap: Action<IMyStoreModel<T>, Record<string, T>>;
+  values: Select<IMyStoreModel<T>, Array<T>>;
+  names: Select<IMyStoreModel<T>, Array<string>>;
+}
+
+const generateModel = <T extends IMyInterface>(): IMyStoreModel<T> => {
+  return {
+    myMap: {},
+    setMyMap: action((state, payload) => {
+      state.myMap = payload;
+    }),
+    values: select(state => {
+      return Object.keys(state.myMap).map(key => state.myMap[key]);
+    }),
+    names: select(state => state.values.map(x => name)),
+  };
+};
+
+const store = createStore(generateModel<Person>());
+
+const foo = Object.values(store.getState().myMap);
+foo[0].name = 'bob';
+
+store.getState().myMap = {
+  foo: { name: 'bob' },
+};
+
+store.getState().values.map(x => x.name);
+
+store.getState().myMap['foo'] = { name: 'bob' };
+
+store.getState().myMap['foo'].name + 'foo';
diff --git a/src/__tests__/typescript/state.ts b/src/__tests__/typescript/state.ts
index 2a3f397..7e4ae8a 100644
--- a/src/__tests__/typescript/state.ts
+++ b/src/__tests__/typescript/state.ts
@@ -3,6 +3,8 @@
 import { State, Thunk, Action, Select, Listen, Reducer } from 'easy-peasy';
 
 type Model = {
+  stateMap: { [key: string]: Array<string> };
+  optionalStateMap?: { [key: string]: Array<string> };
   stateArray: Array<string>;
   stateBoolean: boolean;
   stateDate: Date;
@@ -19,6 +21,7 @@ type Model = {
   listenImp: Listen<Model>;
   reducerImp: Reducer<number>;
   nested: {
+    stateMap: { [key: string]: Array<string> };
     stateArray: Array<string>;
     stateBoolean: boolean;
     stateDate: Date;
@@ -30,10 +33,18 @@ type Model = {
     stateUnion: string | null;
     selectImp: Select<Model, number>;
     reducerImp: Reducer<number>;
+    actionImp: Action<Model, number>;
+    thunkImp: Thunk<Model, string>;
   };
 };
 
 const assert: State<Model> = {
+  stateMap: {
+    foo: ['bar'],
+  },
+  optionalStateMap: {
+    foo: ['bar'],
+  },
   stateArray: [],
   stateBoolean: true,
   stateDate: new Date(),
@@ -47,6 +58,9 @@ const assert: State<Model> = {
   selectImp: 1,
   reducerImp: 1,
   nested: {
+    stateMap: {
+      foo: ['bar'],
+    },
     stateArray: [],
     stateBoolean: true,
     stateDate: new Date(),