首页 > 解决方案 > 如何使用 TypeScript 中的命令模式获得通用推理?

问题描述

我的用例是条件引擎。每个 JS 对象都代表一种我必须评估的条件,如果不支持,则表示错误。

下面的简化代码可以根据需要在控制台中执行和登录true。问题主要是关于打字和推理:

  1. this.actions[name] = command;由于我使用的泛型触发了打字稿打字错误:Type 'Command<D, Out>' is not assignable to type 'Command<CommandData, Out>'.
  2. 尽管给出了我们在上面注册了几行的条件名称,但客户端没有得到数据对象的类型。

直接在 TypeScript Playground 中看到。

图书馆代码

总线必须对命令名称一无所知,因为它们是由客户端动态添加的。它必须根据参数键入execute方法的参数(通过客户端之前使用的方法推断)。datanameadd

// Used internally
interface CommandData {
    [key: string]: any
}

// Externally, the same with a `name` property to identify which Command to call
interface NamedCommandData extends CommandData {
    name: string
}

type Command<In extends CommandData, Out> = (data: In, bus: CommandBus<Out>) => Promise<Out>;

type CommandRegistry<In extends CommandData, Out> = Record<string, Command<In, Out>>

class CommandBus<Out> {
    public actions: CommandRegistry<CommandData, Out> = {};

    add<D extends CommandData, Out>(name: string, command: Command<D, Out>) {
        // I need to use "@ts-ignore" here otherwise it moans:

        // Type 'Command<D, Out>' is not assignable to type 'Command<CommandData, Out>'. 
        // Types of parameters 'data' and 'data' are incompatible. 
        // Type 'CommandData' is not assignable to type 'D'.
        // 'CommandData' is assignable to the constraint of type 'D', but 'D' could be instantiated with a different subtype of constraint 'CommandData'.
        
        this.actions[name] = command;
    }

    async execute(name: string, data: CommandData) : Promise<Out> {
        return this.actions[name](data, this);
    }

    getNameFromData(data: NamedCommandData | any): string | null {
        if (!data || !data.name) return null
        if (!this.actions[data.name]) return null
        return (data as NamedCommandData).name
    }
}

整体结构:

客户代码

  1. 定义自己的命令(道具 + 处理程序)
  2. 将它们注册到总线
  3. 执行
// Command: check equality between two strings

interface StringEqCommandData extends CommandData {
    left: string
    right: string
}

const StringEqCommand: Command<StringEqCommandData, boolean> = async (data) => {
    return data.left === data.right
}

// Command: reduce children command executions and check equality against the expected value

interface EveryCommandData extends CommandData {
    commands: CommandData[]
    value: boolean
}

const EveryCommand: Command<EveryCommandData, boolean> = async (data, bus) => {
    
    const expectedValue = data.value
    const childrenCommands = data.commands

    const results = await Promise.all(
        childrenCommands.map((child) => {
            const name = bus.getNameFromData(child)
            return name ? bus.execute(name, child) : Promise.resolve(false)
        })
    )

    for (const result of results) {
        if (result !== expectedValue)
            return false
    }

    return true 
}

// Instantiate the bus and register the Commands
const bus = new CommandBus<boolean>()
bus.add(`Every`, EveryCommand)
bus.add(`StringEquals`, StringEqCommand)

// /!\ There is no typing here for the data (second arg), based on the name (first arg)
const execution = bus.execute(`Every`, {
    value: true,
    commands: [
        {
            name: `StringEquals`,
            left: `abc`,
            right: `abc`,
        },
        {
            name: `StringEquals`,
            left: `123`,
            right: `123`,
        },
    ]
})

execution
    .then(console.log)
    .catch(console.error)

标签: typescriptdesign-patternstype-inferencetypescript-genericscommand-pattern

解决方案


首先,我尝试使用您当前的代码并修复打字稿问题,但这很快就会变得非常混乱。当您将您的存储actions为字符串索引记录时,您将失去特定键字符串与其特定参数之间的关联。如果actions是一个readonly将命令数组传递给构造函数的属性,我们可以使类泛型依赖于动作对象,这将允许我们要求string名称必须是有效的键,this.actions并且相应的data值与键入此键。

但是您正在使用一个空对象实例化该类并一一添加操作,这意味着打字稿需要在每个add(). 你让add()return 像asserts this is this & {actions: [key in Name]: Command<D, Out> }. 但是这整个方法不起作用,因为您的Command类型bus: CommandBus<Out>作为参数并期望它是通用的,Out仅依赖于。

这是一种“回到绘图板”的情况。试图根据字符串名称推断数据所需的类型是一团糟,因此我们必须考虑命令模式是什么,不是设计用来做什么的。该Command接口有一个execute() 不带参数的唯一方法,因为它封装了它需要的信息。我们需要在这里使用封装。

高阶函数

如果我们不想,我们不需要使用类。让我们定义一个不带参数并返回解析Command<Out>为的函数。PromiseOut

type Command<Out> = () => Promise<Out>

但是字符串比较确实需要一些数据,所以我们使用一个高阶函数来获取数据并返回一个带有Command<boolean>签名的函数。

interface StringEqCommandData {
    left: string
    right: string
}

const createStringEquals = ({left, right}: StringEqCommandData): Command<boolean> => 
async () => {
    return left === right
}

(可能我会提出这两个论点leftright而不是一个键控对象)

我们可以对“every”命令做同样的事情。在这种情况下,我们的参数包括其他Command<boolean>函数。

interface EveryCommandData {
    commands: Command<boolean>[]
    expectedValue?: boolean
}

const createEveryCommand = ({commands, expectedValue = true}: EveryCommandData): Command<boolean> => 
async () => {
    const results = await Promise.all(commands.map( c => c() ) );
    return results.every( r => r === expectedValue );
}

我们根本不需要CommandBus,因为我们可以创建一个函数并自己调用它。

const execution = createEveryCommand({
    commands: [
        createStringEquals({
            left: `abc`,
            right: `abc`,
        }),
        createStringEquals({
            left: `123`,
            right: `123`,
        }),
    ]
})();

游乐场链接

课程

我们还可以使用传统的基于类的方法,我们将其定义Command<Out>为一个对象,该对象的方法execute()不带参数并返回Promise解析为Out.

interface Command<Out> {
    execute(): Promise<Out>;
}

我们可以创建在构造函数中获取数据并通过调用创建的类new

class StringEquals implements Command<boolean> {
    private readonly _data: StringEqCommandData;

    constructor(data: StringEqCommandData) {
        this._data = data;
    }

    public async execute() {
        return this._data.left === this._data.right;
    }
}

const myCommand = new StringEquals({left: "abc", right: "abc"});
myCommand.execute();

您的every案例可以在构造函数中使用一组命令,也可以使用add()类似CommandBus. 它可以存储导致它失败的特定命令。也许除了execute()我们想要求我们的命令有一个name属性。

混合这两种方法,我们还可以使用工厂函数来创建具有execute()函数的对象。

const createStringEquals = (data: StringEqCommandData): Command<boolean> => {
    return {
        execute() {
            return Promise.resolve(data.left === data.right);
        } 
    }
}

推荐阅读