@webquarx/design-patterns
TypeScript icon, indicating that this package has built-in type declarations

1.1.6 • Public • Published

Design Patterns

npm version GitHub Workflow Status Coverage Status Code Style
bundle min size bundle minzip size bundle dependency count bundle tree-shaking support

Description

Light-weight library for simple using design patterns for JavaScript and TypeScript projects.

Chain Of Responsibility

Here is a step definition. The method execute is always asynchronous.

export default class TestChainStep extends ChainOfResponsibilityStep {
    async execute(params: any) {
        console.log('TestChainStep.execute');
        return await super.execute(params);
    }
}

Constructing a Chain

There are two ways to construct a chain.

// Classic way

const firstStep = new TestChainStep();
const secondStep = new TestChainStep();
const thirdStep = new TestChainStep();

firstStep
    .setNext(secondStep)
    .setNext(thirdStep);

await firstStep.execute();

It's possible to focus on constructing a chain, not on definition of the variables and a series of the setNext methods.

// Simple way

const chain = new ChainOfResponsibility([
    new TestChainStep(),
    new TestChainStep(),
    new TestChainStep(),
]);

await chain.execute();

The method setNext will be called automatically in a simple way. There is no need to call it for every step when defining a chain. The order of steps in a chain is the same as in array for constructor of ChainOfResponsibility class, which extends the usual ChainOfResponsibilityStep.

Using a Function instead of a Step

There is a simple way to use a function instead of a chain step class.

const chain = new ChainOfResponsibility([
    (execute, param) => {
        console.log('function execute');
        return execute(param);
    },
    new TestChainStep(),
]);

chain.execute(param);

The execute parameter is a function which executes the next step. It's analog of super.execute() in the version of the step class.

Constructing with useChain Function

If you prefer to use functions instead of classes, there is a way to create a chain step or the whole chain using only the useChain function.

const step = useChain(
    (execute, param) => {
        console.log('function execute');
        return execute(param);
    },
);

const chain = useChain([
    step,
    (execute, param) => {
        console.log('function execute 2');
        return execute(param);
    },
    new TestChainStep(),
]);

chain.execute(param);

Merging Chains

Usually two chains can not be merged without loosing steps in the first one if there is no link to the last step. That's why there is the setLast method for appending chains to the end of a chain.

class Step1 extends ChainOfResponsibilityStep {}
// Step2, Step3, Step4 have the same class definitions...

const chain1 = new Step1();
chain1.setNext(new Step2());

const chain2 = new Step3();
chain2.setNext(new Step4());

// Step2 will have nextStep to Step3
chain1.setLast(chain2);

Merging With Nesting

Merging chains also works with nesting chains. That forms a one long chain. It makes possible to construct parts of chains in different modules and combine them later into one long chain.

class Step1 extends ChainOfResponsibilityStep {}
// Step2, Step3, Step4 have the same class definitions...

const chain1 = new ChainOfResponsibility([
    new Step1(),
    new Step2(),
]);

const chain2 = new ChainOfResponsibility([
    new Step3(),
    new Step4(),
]);

const chain = new ChainOfResponsibility([chain1, chain2]);
// chain.execute calls execute method from all steps 
chain.execute(param);

The useChain function works the same way, e.g. where sub-chains can be created in the factory class:

const chain = useChain([
    useChain([
        new Step1(),
        (execute, param) => execute(param), // Step2
    ]),
    useChain([
        (execute, param) => execute(param), // Step3
        new Step4(),
    ]),
]);
chain.execute(param);

Chain with a Condition

There is a way to execute a chain or a sub-chain with condition without interrupting the whole chain. In the example below Step1 and Step2 will never be executed, while Step3 will be:

const chain = new ChainOfResponsibility([
    {
        chain: [
            new Step1(),
            new Step2(),
        ],
        canExecute: false,
    },
    new Step3(),
]);
chain.execute();

The chain property can be a step, a chain, a step function or an array of them. The canExecute can be a boolean value or a synchronous or asynchronous function which returns a boolean.

It is also possible to use a conditional chain with the useChain function or the ChainOfResponsibility class. E.g. Step1 will be executed only when canExecute returns true.

const chain = useChain({
    chain: new Step1(),
    canExecute: (params) => params.canExecute,
});
chain.execute({canExecute: true});

It also works with sub-chains:

const chain = useChain([
    {
        chain: new Step1(),
        canExecute: (params) => params.canExecute,
    },
    {
        chain: new Step2(),
        canExecute: (params) => !params.canExecute,
    },
]);
chain.execute({canExecute: true});

The example can be made simpler by using elseChain. If the canExecute condition is false, the chain declared with the elseChain property will be executed. This property is optional.

const chain = useChain({
    chain: new Step1(),
    elseChain: new Step2(),
    canExecute: (params) => params.canExecute,
});
chain.execute({canExecute: true});

There is invalid usage of canExecute in the conditional chain, which can lead to a typical issue. When declaring the context parameter before the chain, calculating it in the previous step, and using it in the next step, the calculation of canExecute will be made before the conditional chain execution, and the calculation result of the previous step will not be used. Use the canExecute function to avoid this issue. The example below demonstrates the problem:

const context = {canExecute: true};

const chain = useChain([
    (execute, param) => {
        param.canExecute = false;
        return execute(context);
    },
    // Invalid use of the context parameter
    {
        chain: new Step1(),
        // `canExecute` is true! It was declared as true and was not calculated
        canExecute: context.canExecute, // use a function instead
        // canExecute: (param) => param.canExecute,
    },
]);

chain.execute(context);

There is an example using an asynchronous canExecute method. The entire chain's execution will be paused until the asynchronous canExecute method of the chain step is complete.

const chain = useChain({
    chain: new Step1(),
    canExecute: () => new Promise<boolean>((resolve) => {
        setTimeout(() => {
            resolve(true);
        }, 50);
    }),
});
await chain.execute();

Using the Command as a Chain Of Responsibility Step

A command can be used as a chain of responsibility step by using the useChain function. The parameters of the step's execute method will be passed as parameters to the canExecute and execute methods of the command.

Additionally, asynchronous canExecute method for command is supported.

type Props = { foo: number };
const decCommand = useCommand({
    execute: async (context) => context.sum--,
    canExecute: () => false, // command will not be executed
});
const incCommand = useCommand<Props>(
    async function (this: Props, context: { sum: number }) {
        context.sum += this.foo;
    },
    { foo: 1 }, // increment by 1
);
const chain = useChain([
    decCommand,
    incCommand,
]);
const context = { sum: 1 };
await chain.execute(context);
console.log(context.sum); // 2

Command

Here is a command definition. The execute method is always asynchronous and abstract, requiring a definition.

class TestCommand extends Command {
    async execute(params) {
        return params;
    }
}

A typical Command instance does not accept parameters in its execute method, which helps to make the object more independent of the context, although such a capability is retained.

class TestCommand extends Command {
    constructor(readonly param: object) {
        super();
    }

    async execute() {
        return this.param;
    }
}

Constructing a Command

The Command pattern is typically used to encapsulate requests as objects, separating the request sender from its receiver. Thus, operation parameters are usually passed through the Command class constructor when creating the object. There are two ways to construct a command.

// Classic way
class TestCommand extends Command {
    private setValue: (value: string) => string;
    private value: string;

    constructor(
        setValue: (value: string) => string,
        value: string,
    ) {
        super();
        this.setValue = setValue;
        this.value = value;
    }

    async execute() {
        return this.setValue(this.value);
    }
}

const cmd = new TestCommand(
    (value: string) => value,
    'test',
);
await cmd.execute();

It's possible to focus on writing command execution without the need to define class properties since they will be automatically defined.

// Simple way
class TestCommand extends Command<{
    setValue: (value: string) => string,
    value: string,
}> {
    async execute() {
        return this.setValue(this.value);
    }
}

const cmd = new TestCommand({
    setValue: (value: string) => value,
    value: 'test',
});
await cmd.execute();

The base Command class provides creation, type control, and value setting for constructor parameters. However, be careful and do not pass object with execute and canExecute keys to the constructor, as it's prohibited.

Command with a Condition

There is a way to prevent the execution of a command if it is not allowed. To do this, you can override the canExecute method by inheriting from the Command class. The canExecute method returns true by default. The params will be the same as for execute method.

class TestCommand extends Command {
    canExecute(params) {
        return !!params || true;
    }
}

The canExecute method of a command can also be declared as asynchronous.

Constructing with useCommand Function

There is a way to create a command using only the useCommand function. The first parameter can be an execute function.

const cmd = useCommand(
    async (param) => param,
);
await cmd.execute('test');

Properties can also be specified in a manner similar to how it's done in class definitions. Pass the second parameter for properties within the this object accessible in the execute function.

Note: arrow function should not be used for execute, as it does not allow access to this within property definitions.

const cmd = useCommand<{ value: number }>(
    async function (this: { value: number }, param: number) {
        this.value += param;
        console.log(this.value); // 2
    },
    { value: 1 },
);
await cmd.execute(1);

The first parameter can be an object with execute and canExecute definitions. Both methods can be asynchronous.

const cmd = useCommand({
    execute: async (param) => param,
    canExecute: () => false,
});
if (cmd.canExecute && cmd.canExecute()) {
    await cmd.execute('test'); // will be called
}

Merging object properties with useCommand

Pass an object to useCommand which, in the execute function, will have access to its properties via this.

Note: do not define an object directly in the useCommand function parameter, as it may result in a TypeScript type casting error. To circumvent this, assign the object to a variable one line above.

const obj = {
    foo: 'test',
    async execute() {
        return this.foo;
    },
};
const cmd = useCommand(obj);
await cmd.execute();

It's possibly to merge object properties with properties passed to useCommand and access them in the execute function.

type Foo = { foo: string };
type Bar = { bar: string };

const obj = {
    foo: 'default',
    async execute(this: Foo & Bar) {
        return this.foo + this.bar;
    },
};
const cmd = useCommand<Bar>(obj, { bar: '!' });
const res = await cmd.execute();

The obj.foo stores the default value, which value can be overridden with props object in the useCommand function.

// ... example above
const cmd = useCommand<Bar>(obj, { bar: '!', foo: 'test' });

Using the Chain Of Responsibility as a Command

A chain of responsibility can be employed as a command by calling the useCommand function, with the first step of the chain passed into it.

The parameters of the execute method of the command are passed as parameters to the execute method of the chain step.

const chain = useChain(
    (execute, context) => console.log(context.foo) // test
);
const cmd = useCommand(chain);
await cmd.execute({foo: 'test'});

One possible scenario for using a chain as a command is for post-processing after a specific action. For instance, the first step in the chain could involve making a request, and the subsequent step could handle processing the response and adapting it to a common interface.

Several such chains can be executed simultaneously by encapsulating each one within a command and using an Invoker e.g. for asynchronous execution.

As result, the caller's side will be completely abstracted from data retrieval and the handling of specific responses.

By default, a chain doesn't support the canExecute method, whereas a command does. Here is an example of how to declare a command with a chain and a canExecute method, including an additional chain step with a canExecute function:

// a chain declared elsewhere
const someChain = useChain(
    (execute, context) => console.log(context.foo) // test
);
// ...
const cmd = useCommand(
    useChain({
        chain: someChain,
        canExecute: () => true,
    }),
);
await cmd.execute({foo: 'test'});

Invoker

Invoker allows the creation of a queue with commands for sequential or parallel execution.

Constructing an Invoker

It is possible to create an Invoker with just one command:

const command = new TestCommand();
const invoker = new Invoker(command);

Alternatively, you can create an Invoker with an array of commands:

const invoker = new Invoker([
    new TestCommand(),
    useCommand(async (param) => param),
]);

You can also create an Invoker from an array with any data and the createCommand function:

const invoker = new Invoker(
    [1, 2, 3],
    (item) => new TestCommand(item),
);

Setting Execution Limits

The Invoker supports limits for executing commands, which can be set for the entire execution process.

Concurrent

The count of concurrently running commands; it will run all commands concurrently if not provided.

const invoker = new Invoker([]);
invoker.limit({ concurrent: 2 });

Retries

The retries can be either a number or a function.

The number of retry attempts for executing each command. The default value for the command is 1. If the command throws an error, it will be executed again until the specified number of retries is reached.

const invoker = new Invoker([/* commands */]);

// Each command will be executed 3 times in case of an error
// after which it will be marked as failed.
invoker.limit({ retries: 3 });

The asynchronous function that will be executed if the command throws an error.

Parameters:

command: a command that has thrown an error.

attempt: the current number of retries.

error: the thrown error.

...args: all the arguments that were passed for executing the command.

It should return true to indicate that the retrying of the command execution should continue. Otherwise, the command execution will be stopped.

const invoker = new Invoker([/* commands */]);
invoker.limit({
    // continue executing until the third attempt
    retries: async (command, attempt, error, arg1) => {
        return attempt < 3;
    },
});

Timeout

The timeout specifies the maximum number of milliseconds allowed for each command to complete. If the timeout is reached before the command has completed, the command will fail with 'Operation timeout' exception.

const invoker = new Invoker([/* commands */]);
invoker.limit({ timeout: 100 }); // Set a 100ms execution time limit for each command

The timeout and retries limits can be used together. If both are specified and the command reaches the timeout, it will raise an exception. With the retries parameter the Invoker will attempt to run such command again until the retries value is reached or task completes.

Invoker Tasks

Each command can be represented as a task. This can be useful for monitoring command status or specifying custom execution limits.

For these cases, the Invoker constructor accepts task object where the command must be assigned to the command property. Other task fields are optional.

const task = {
    command: useCommand(/*...*/),
};
const invoker = new Invoker(task);

Task Statuses

For monitoring the current task status, use the status field. The possible values are:

idle: the task has been added to the Invoker and is waiting to start executing with the Invoker method or is in queue for its turn.

pending: the task has been started, currently, it's running while the Invoker is waiting for its completion.

fulfilled: the task has been completed successfully without any errors.

rejected: the task has been completed with an error.

Task Limits

Each command could have its own limit, which overrides the common limit specified for the Invoker.

retries: the same as Invoker retries limit. Represents the number of retry attempts for executing a command or an asynchronous function indicating whether the command execution should be continued.

The value for the command overrides the retries value specified in the Invoker.limit method.

const invoker = new Invoker([
    useCommand(/*...*/), // invoker.limit retries is used
    {
        command: useCommand(/*...*/),
        retries: 4, // will override invoker.limit retries
    },
    {
        command: useCommand(/*...*/),
        // will override invoker.limit retries
        retries: async (command, attempt, error, arg1) => {
            return attempt < 3; // continue executing until the third attempt
        },
    },
]);
invoker.limit({ retries: 3 });

timeout: the same as Invoker timeout limit. Represent the maximum number of milliseconds allowed for the command to complete.

const invoker = new Invoker([
    useCommand(/*...*/), // invoker.limit timeout is used
    {
        command: useCommand(/*...*/),
        timeout: 4000, // will override invoker.limit timeout
    },
]);
invoker.limit({ timeout: 3000 });

Task Result

After executing a command its result will be available in the result task field. The field contains two values: value and error. If the task is not completed both fields are undefined.

result.value: the value returned from the command's execute method

result.error: the error caught while executing the command

Task Key

Each task has its own unique key, which is automatically generated in UUID format if not provided.

const task = {
    command: useCommand(/*...*/),
};
const invoker = new Invoker(task);
console.log(task.key); // b81141e5-19fe-465d-a0b6-f7b7b05d4e58

The key generation algorithm does not utilize a crypto library or any other dependencies to maintain zero dependency and minimize package size. It is based on a well-known algorithm using Math.random, as the task's key does not necessitate high randomness or the avoidance of repeating the same random sequence.

In any case, it is possible to use any other UUID implementation when creating a task. The task.key will be automatically generated only if it was not provided.

const invoker = new Invoker(
    [1, 2, 3],
    (item) => ({
        command: new TestCommand(item),
        key: item,
    }),
);

Since the task key in unique, it can be used within templates with UI frameworks like Vue or React. For example, using the task key in a Vue template:

<ul>
  <li v-for="task in tasks" :key="task.key">...</li>
</ul>

useTask Function

Create a task using the useTask function to ensure that all necessary fields are present in the task object. The task will contain the following object with default values:

const task = useTask(command);
console.log(task);
/*
 {
    command: Command,
    status: 'idle',
    retries: 1,
    result: { error: undefined, value: undefined },
    key: 'xxxxxxx-...xxx',
}
 */

While executing the command, the Invoker will change status and result fields of the task. This also makes it possible to use task objects with libraries that observe object states, such as Vue or MobX.

import { reactive, watchEffect } from 'vue';

const task = useTask({
    command: new TestCommand(),
    retries: 3,
});

const reactiveTask = reactive(task);
watchEffect(() => {
    console.log(reactiveTask.status);
});

await new Invoker(reactiveTask).parallel();
/*
idle
pending
fulfilled
 */

Retrieving all tasks

The Invoker supports a read-only tasks property for retrieving all task objects in a single array. The result from the getter is an array that is not a copy. For this reason, the array is safeguarded with TypeScript's ReadonlyArray.

const invoker = new Invoker(
    [1, 2, 3],
    (item) => new TestCommand(item),
);
invoker.tasks.forEach((item) => console.log(item.status))

The property is accessible at any time, even when tasks are running.

Execution Strategies

Execution strategies control the execution process by determining whether to stop or continue running the next task, and by setting how to handle the execution results. A strategy can be specified with the invoker strategy method. It returns the same invoker object.

await new Invoker([/* commands */])
    .strategy(InvokerStrategies.all)
    .parallel();

all Strategy

The all strategy is the default strategy. It is similar to the Promise.all method. Once all tasks have finished, their results will be returned as an array.

If any command encounters an error, the promise will be promptly rejected with the first error and the Invoker will stop the execution. The remaining tasks that are still in progress or pending will be canceled with an Operation Canceled error.

The strategy behaviour is similar to the Promise.all method.

function resolveTimeout(time) {
    return new Promise((resolve) => {
        setTimeout(resolve, time, time);
    });
} 

const invoker = new Invoker([
    useCommand(async () => await resolveTimeout(10)),
    useCommand(async () => await resolveTimeout(20)),
]);

invoker.strategy(InvokerStrategies.all);
const res = await invoker.parallel();

console.log(res);
// Expected output: Array [10, 20]

allSettled Strategy

The allSettled strategy waits until each task is finished, regardless of whether it was successful or not. It never fails with an exception for any task, even if a task has failed. The Operation Canceled exception cannot occur for any task.

When all tasks are finished, the results will be provided as an array of objects. Each object will contain a value field with the returned task value if the task was successful, or an error field containing the task error if it failed. For example, if a task fails due to a timeout with an 'Operation Timeout' exception, the error field will contain this exception.

The strategy behaviour is similar to the Promise.allSettled method.

function rejectTimeout(time) {
    return new Promise((resolve, reject) => {
        setTimeout(() => { reject(new Error('rejected')) }, time);
    });
}

const invoker = new Invoker([
    useCommand(async () => await rejectTimeout(10)),
    useCommand(async () => await resolveTimeout(20)),
]);

invoker.strategy(InvokerStrategies.allSettled);
const res = await invoker.parallel();

console.log(res);
// Expected output: Array [{error: [Error: rejected]}, {value: 20}]

any Strategy

The any strategy allows waiting until the first successful task is finished, returning its result. The remaining tasks that are still in progress or pending will be canceled with an Operation Canceled error.

If all tasks finish with errors, it will fail with an AggregateError containing the errors for each task.

The strategy behaviour is similar to the Promise.any method.

const invoker = new Invoker([
    useCommand(async () => await rejectTimeout(10)), // result: timeout error
    useCommand(async () => await resolveTimeout(20)), // result: 20
    useCommand(async () => await resolveTimeout(30)), // result: Operation Cancelled error
    useCommand(async () => await rejectTimeout(20)), // result: Operation Cancelled error
]);

invoker.strategy(InvokerStrategies.any);
const res = await invoker.parallel();

console.log(res);
// Expected output: 20

race Strategy

The race strategy waits for the first task to finish. It returns the result of the first task if it is successful. If the first finished task fails, it will return the task's error.

The remaining tasks that are still in progress or pending will be canceled with an Operation Canceled error.

The strategy behaviour is similar to the Promise.race method.

Here is an example that fails:

const invoker = new Invoker([
    useCommand(async () => await rejectTimeout(10)), // result: timeout error
    useCommand(async () => await resolveTimeout(20)), // result: Operation Cancelled error
]);

invoker.strategy(InvokerStrategies.race);
const res = await invoker.parallel();

console.log(res);
// Expected output: [Error: timeout error]

Here is a successful example:

const invoker = new Invoker([
    useCommand(async () => await rejectTimeout(30)), // result: Operation Cancelled error
    useCommand(async () => await resolveTimeout(10)), // result: 10
    useCommand(async () => await resolveTimeout(20)), // result: Operation Cancelled error
    useCommand(async () => await rejectTimeout(20)), // result: Operation Cancelled error
]);

invoker.strategy(InvokerStrategies.race);
const res = await invoker.parallel();

console.log(res);
// Expected output: 10

Custom Strategy

A custom strategy allows for customizing the task execution process and avoiding the limitations of predefined strategies in specific situations, especially when tasks return specific results and there is a need to stop or continue the execution process based on result analysis.

To set up a custom strategy, specify a function with the settled task as the argument. Return an object from the function with the two properties:

runNext: a boolean value indicating whether the invoker should continue the task execution.

settle: a string value describing how to finish the task execution.

Here are the possible values for the settle property:

all: Invoker returns an array with all tasks' results as objects with value and error properties.

error: Invoker returns the first error, excluding the Operation Cancelled error.

aggregateError: Invoker returns an AggregateError error containing all errors as an array in the errors property.

successful: Invoker returns the result of the first successful task.

aggregateSuccessful: Invoker returns an array containing the successful task results.

Here is an example of implementing the allSettled strategy as a custom strategy:

const allSettled = () => ({
    runNext: true,
    settle: 'all',
});

await new Invoker([/* commands */])
    .strategy(allSettled)
    .parallel();

Parallel

Run multiple commands simultaneously, without waiting for each one to finish before starting the next.

You can limit the number of concurrently running commands using the limit method. Without setting a command execution limit, the method will run all commands simultaneously.

Using the strategy method, you can specify how to proceed with the next task during execution, such as stopping on errors. You can also control how to return the result, for example, as an array of fulfilled tasks or as an AggregateError if errors occur.

Without a concurrency limit and using the default all strategy, its execution is equivalent to the Promise.all method.

The method also supports any number of arguments, which will be passed to the canExecute and execute methods of commands.

await new Invoker([/* commands */]).limit({ concurrent: 2 }).parallel();

Errors

Operation Timeout

An operation timeout error will be thrown when a task is reached the timeout.

message: Operation Timeout

code: ETIME

details.description: The task with the key {task-key} has reached the timeout.

details.task: The task encountering the error

Operation Canceled

An "Operation Canceled" error will be thrown when a task is canceled. This can occur when executing in parallel and a previous task was rejected with an error.

message: Operation Canceled

code: ECANCEL

details.description: The task with the key {task-key} was canceled.

details.task: The task encountering the error

Package Sidebar

Install

npm i @webquarx/design-patterns

Weekly Downloads

109

Version

1.1.6

License

Apache-2.0

Unpacked Size

222 kB

Total Files

9

Last publish

Collaborators

  • evgenzvg