mercredi 5 août 2020

How to get Generic Inference using the Command Pattern in TypeScript?

My use case is a Condition engine. Each JS object represents a type of condition that I must evaluate, or error if it is not supported.

The simplified code below works as in it executes and logs true in the console as needed. The problems are mostly about typing and inference:

  1. this.actions[name] = command; triggers a typescript typing error because of the generics I use: Type 'Command<D, Out>' is not assignable to type 'Command<CommandData, Out>'.
  2. The client doesn't get typings for the data object despite giving the Condition name we registered a couple of lines above.

Directly see in the TypeScript Playground.

The Library Code

The Bus must know nothing about Command names since these are added dynamically by the client. It must type the execute method's data argument based on the name argument (inferred via the add method used earlier on by the client).

// 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
    }
}

Overall Structure:

  • Command - interface representng the handler of a specific command type; each receives its data and the bus (to be able to process children if there are any)
    • EveryCommand - received EveryCommandData so to take a boolean expected value and children Commands to evaluate
    • StringEqualsCommand - receives StringEqualsCommandData which are two strings (data.left and data.right)
  • CommandData is a simple interface all concrete Commands' input data extend. NamedCommandData the public version with an additional name: string to identify the right handler (e.g. name: "StringEquals").
  • CommandBus is the entry point for clients. They can add their Commands as well as execute them.
  • CommandRegistry is where Command names and handlers are stored for lookup and execution.

The Client Code

  1. Defines its own Commands (props + handler)
  2. Register them with the Bus
  3. Execute
// 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)

Aucun commentaire:

Enregistrer un commentaire