I have run into a seemingly intractable problem with a tool I am writing. The tool allows defining all action types, actions, selectors, and reducer functions based on the shape of an initial state object. Before I delve into specifics, let me first describe the intended usage.
It begins with an enum that defines all the keys for properties inside the state, such as:
enum MyEnum {
TEST = 'test1',
TEST2 = 'test2',
}
Using the enum, we then define an initial state.
const exampleState = {
[MyEnum.TEST]: new Entry<string>(),
[MyEnum.TEST2]: new Entry<number>(),
};
Please note that, beyond the generic type parameter used on the Entry class, using type annotation to declare this state object is unnecessary and would defeat the tool's purpose because the initial state is the source of truth for the state's shape and types.
Skipping other initialization steps included in the full version of this tool, we can use individual functions belonging to it independently without ill-effect.
Create action types:
const actionTypes = createActionTypes<typeof MyEnum>(MyEnum);
Create actions:
const actions = createActions<typeof MyEnum, typeof exampleState>(actionTypes, MyEnum);
If I start writing some code, I get syntax suggestions showing that this object has two functions: "test1" and "test2".
If I run the functions with correct and incorrect parameters, I get the intended effect. The first entry is a string, so the parameter should also be a string. Typescript correctly throws errors when calling the test1 and test2 functions with a number and a string, respectively.
However, it's boring using the keys as function names. It'd be much nicer to call the test1 function something like updateTest1 because that is what the action does.
I have a second creator function to call, which does just that.
const aliasedActions = createAliasedActions(actions, MyEnum);
Like actions before this, I get proper syntax suggestions when I start accessing the properties of aliasedActions, only this time, I see "updateTest1" and "updateTest2"
Next is where I run into a problem. When I call any of the functions with a string or a number, TypeScript does not throw an error on any of them!
aliasedActions.updateTest1(1); // okay
aliasedActions.updateTest1('1'); // okay
aliasedActions.updateTest2(1); // okay
aliasedActions.updateTest2('1'); // okay
After inspecting the types, the parameter appears to be a union of all the possible types (string | number | undefined) instead of the specific type required, given the schema of the initial state (updateTest1 should take string | undefined and updateTest2 should take number | undefined).
What happened?
The type definition for Action takes a generic type parameter for the key of the initial state and uses it to reference the type for the parameter "payload" (S[K]['value']).
class Entry<T = any> {
value?: T;
constructor(
value?: T,
) {
this.value = value;
}
}
type MyState<
E extends { [key: string]: string },
> = {
[key in E[keyof E]]: Entry
};
type Action<
E extends { [key: string]: string },
S extends MyState<E>,
K extends E[keyof E],
> = (
payload: S[K]['value'],
) => ({
type: ActionTypes<E>[K],
key: K,
payload: S[K]['value'],
});
type Actions<
E extends { [key: string]: string },
S extends MyState<E>,
> = {
[K in E[keyof E]]: Action<E, S, K>
};
The above code appears correct, and testing of actions confirms it. However, aliasedActions has a somewhat different type of definition, and this is where I cannot figure out what I should do differently.
type ActionName<
E extends { [key: string]: string },
K extends E[keyof E],
> = `update${Capitalize<K>}`;
type AliasedActions<
E extends { [key: string]: string },
S extends MyState<E>,
> = {
[K in E[keyof E] as ActionName<E, K>]: Action<E, S, E[keyof E]>;
};
Since within the mapped object type, I assert K, a value in the enum, as a template literal, it appears as though I can no longer use K as the third generic type parameter for Action. Hence, Action<E, S, E[keyof E]> instead of Action<E, S, K>. However, a side effect is that Action<E, S, E[keyof E]> makes the parameter "payload" a union of all the potential types instead of a specific type.
I need help figuring out a way around this. It should be simple, but nothing I have tried worked. Is there some way I can pass in K as the original type yet still have the key remain ActionName<E, K>?
Example code for testing:
class Entry<T = any> {
value?: T;
constructor(
value?: T,
) {
this.value = value;
}
}
type MyState<
E extends { [key: string]: string },
> = {
[key in E[keyof E]]: Entry
};
type Action<
E extends { [key: string]: string },
S extends MyState<E>,
K extends E[keyof E],
> = (
payload: S[K]['value'],
) => ({
type: ActionTypes<E>[K],
key: K,
payload: S[K]['value'],
});
type Actions<
E extends { [key: string]: string },
S extends MyState<E>,
> = {
[K in E[keyof E]]: Action<E, S, K>
};
type ActionTypes<
E extends { [key: string]: string },
> = {
[K in E[keyof E]]: ActionTypeName<E, K>
};
type ActionTypeName<
E extends { [key: string]: string },
K extends E[keyof E],
> = `UPDATE_${Uppercase<K>}`;
type ActionName<
E extends { [key: string]: string },
K extends E[keyof E],
> = `update${Capitalize<K>}`;
type AliasedActions<
E extends { [key: string]: string },
S extends MyState<E>,
> = {
[K in E[keyof E] as ActionName<E, K>]: Action<E, S, E[keyof E]>;
};
const createActionTypeName = <
E extends { [key: string]: string },
K extends E[keyof E] = E[keyof E],
>(key: K): ActionTypeName<E, K> => {
const postFix = key.toUpperCase() as Uppercase<K>;
return `UPDATE_${postFix}`;
};
const createActionTypes = <
E extends { [key: string]: string },
>(
keys: E,
): ActionTypes<E> => {
const result: Partial<ActionTypes<E>> = {};
for (const k in keys) {
const key: E[keyof E] = keys[k];
result[key] = createActionTypeName<E>(key);
}
return result as unknown as ActionTypes<E>;
};
const createAction = <
E extends { [key: string]: string },
S extends MyState<E>,
K extends E[keyof E] = E[keyof E],
>
(
type: ActionTypes<E>[K],
key: K,
): Action<E, S, K> => (
payload: S[K]['value'],
): ReturnType<Action<E, S, K>> => ({
type,
key,
payload,
});
const createActions = <
E extends { [key: string]: string },
S extends MyState<E>,
>(
actionTypes: ActionTypes<E>,
keys: E,
): Actions<E, S> => {
const result: Partial<Actions<E, S>> = {};
for (const k in keys) {
const key: E[keyof E] = keys[k];
const actionType = actionTypes[key];
result[key] = createAction<E, S>(actionType, key);
}
return result as unknown as Actions<E, S>;
};
const createActionName = <
E extends { [key: string]: string },
K extends E[keyof E] = E[keyof E],
>(
key: K,
): ActionName<E, K> => {
const [first, ...letters] = key;
const postFix = [first.toUpperCase(), ...letters].join('') as Capitalize<K>;
return `update${postFix}`;
};
const createAliasedActions = <
E extends { [key: string]: string },
S extends MyState<E>,
>(
actions: Actions<E, S>,
keys: E,
): AliasedActions<E, S> => {
const result: Partial<AliasedActions<E, S>> = {};
for (const k in keys) {
const key = keys[k];
const name = createActionName<E>(key);
result[name] = actions[key];
}
return result as unknown as AliasedActions<E, S>;
};
enum MyEnum {
TEST = 'test1',
TEST2 = 'test2',
}
const exampleState = {
[MyEnum.TEST]: new Entry<string>(),
[MyEnum.TEST2]: new Entry<number>(),
};
const actionTypes = createActionTypes<typeof MyEnum>(MyEnum);
const actions = createActions<typeof MyEnum, typeof exampleState>(actionTypes, MyEnum);
// This is the desired effect
const test = actions.test1('test');
const test2 = actions.test1(1); // TS2345: Argument of type '1' is not assignable to parameter of type 'string | undefined'.
// This is also the desired effect
const test3 = actions.test2('test'); // TS2345: Argument of type 'test' is not assignable to parameter of type 'number | undefined'.
const test4 = actions.test2(1); // okay
// After changing the object so that the functions are named based on the key, type information is lost.
const aliasedActions = createAliasedActions(actions, MyEnum);
// This is not the intended effect!
aliasedActions.updateTest1(1); // okay
aliasedActions.updateTest1('1'); // okay
aliasedActions.updateTest2(1); // okay
aliasedActions.updateTest2('1'); // okay
/*
TS2345: Argument of type '{}' is not assignable to parameter of type 'string | number | undefined'.
Type '{}' is not assignable to type 'number'.
*/
// Typescript correctly throws an error on this, showing that it's not considering the param "any"
aliasedActions.updateTest2({});



