我正在编写的工具遇到了一个看似棘手的问题。该工具允许根据初始状态对象的形状定义所有动作类型、动作、选择器和缩减器函数。在我深入研究细节之前,让我先描述一下预期的用途。
它以一个枚举开始,它定义了状态内部属性的所有键,例如:
enum MyEnum {
TEST = 'test1',
TEST2 = 'test2',
}
使用枚举,然后我们定义一个初始状态。
const exampleState = {
[MyEnum.TEST]: new Entry<string>(),
[MyEnum.TEST2]: new Entry<number>(),
};
请注意,除了 Entry 类上使用的通用类型参数之外,使用类型注释来声明此状态对象是不必要的,并且会破坏该工具的目的,因为初始状态是状态形状和类型的真实来源。
跳过此工具完整版中包含的其他初始化步骤,我们可以独立使用属于它的各个功能而不会产生不良影响。
创建动作类型:
const actionTypes = createActionTypes<typeof MyEnum>(MyEnum);
创建动作:
const actions = createActions<typeof MyEnum, typeof exampleState>(actionTypes, MyEnum);
如果我开始编写一些代码,我会得到语法建议,显示该对象有两个函数:“test1”和“test2”。
如果我使用正确和不正确的参数运行函数,我会得到预期的效果。第一个条目是一个字符串,所以参数也应该是一个字符串。分别使用数字和字符串调用 test1 和 test2 函数时,Typescript 会正确抛出错误。
但是,使用按键作为函数名很无聊。将 test1 函数称为
updateTest1
会更好,因为这就是动作的作用。
我有第二个创建者函数可以调用,它就是这样做的。
const aliasedActions = createAliasedActions(actions, MyEnum);
就像之前的操作一样,当我开始访问
aliasedActions
的属性时,我得到了正确的语法建议,只有这一次,我看到了“updateTest1
”和“updateTest2
”
接下来是我遇到的问题。当我用字符串或数字调用任何函数时,TypeScript 不会对它们中的任何一个抛出错误!
aliasedActions.updateTest1(1); // okay
aliasedActions.updateTest1('1'); // okay
aliasedActions.updateTest2(1); // okay
aliasedActions.updateTest2('1'); // okay
检查类型后,参数似乎是所有可能类型的联合 (
string | number | undefined
),而不是所需的特定类型,给定初始状态的模式(updateTest1
应该采用 string | undefined
和 updateTest2
应该采取number | undefined
)。
发生了什么事?
Action
的类型定义采用泛型类型参数作为初始状态的键,并使用它来引用参数“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>
};
上面的代码看起来是正确的,
actions
的测试证实了这一点。然而,aliasedActions
有一些不同类型的定义,这是我不知道我应该做什么不同的地方。
type ActionTypeName<
E extends { [key: string]: string },
K extends E[keyof E],
> = `UPDATE_${Uppercase<K>}`;
类型 AliasedActions<
E extends { [key: string]: string },
S extends MyState,
> = {
[K in E[keyof E] as ActionName
由于在映射对象类型中,我断言枚举中的一个值
K
作为模板文字,看来我不能再使用 K
作为 Action
的第三个泛型类型参数。因此,Action<E, S, E[keyof E]>
而不是 Action<E, S, K>
。然而,副作用是 Action<E, S, E[keyof E]>
使参数“payload”成为所有潜在类型的联合,而不是特定类型。
我需要帮助找出解决方法。它应该很简单,但我没有尝试过。有什么方法可以让我将
K
作为原始类型传递,但仍然保留密钥 ActionName<E, K>
?
测试示例代码:
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({});