Skip to content

Instantly share code, notes, and snippets.

@CMCDragonkai
Last active September 30, 2021 07:41
Show Gist options
  • Save CMCDragonkai/1dbf5069d9efc11585c27cc774271584 to your computer and use it in GitHub Desktop.
Save CMCDragonkai/1dbf5069d9efc11585c27cc774271584 to your computer and use it in GitHub Desktop.
Asynchronous Initialisation and Deinitialisation for JavaScript Classes #typescript #javascript
/**
* Use this when object lifetime matches object "readiness"
*/
class X {
protected _destroyed: boolean = false;
public static async createX(): Promise<X> {
return new X;
}
protected constructor() {
}
get destroyed(): boolean {
return this._destroyed;
}
public async destroy(): Promise<void> {
try {
if (this._destroyed) {
return;
}
this._destroyed = true;
} catch (e) {
this._destroyed = false;
throw e;
}
}
public async doSomething(): Promise<void> {
if (this._destroyed) {
throw new Error('X is destroyed');
}
console.log('X did something');
}
}
class Y extends X {
public static async createY(): Promise<Y> {
return new Y;
}
protected constructor() {
super();
}
public async destroy(): Promise<void> {
await super.destroy();
}
}
async function main() {
const x = await X.createX();
await x.destroy();
// once destroyed you must never use it
}
/**
* X is a create & destroy pattern
* This uses composition pattern, not inheritance
*/
class X {
/**
* Public to indicate non-encapsulation of Y
*/
public y: Y;
/**
* Protected to indicate Z it is encapsulated and managed by X
*/
protected z: Z;
protected _destroyed: boolean = false;
/**
* Y is a dependency but not encapsulated
* Z is an optional and overridable encapsulated dependency
* Default parameters are always put in the createX, not in the constructor
*/
public static async createX({
y,
z
}: {
y: Y,
z?: Z
}): Promise<X> {
z = z ?? await Z.createZ();
const x = new X({ y, z });
return x;
}
/**
* It is assumed that Y and Z are ready to be used
*/
protected constructor({ y, z }: { y: Y, z: Z }) {
this.y = y;
this.z = z;
}
get destroyed() {
return this._destroyed;
}
public async destroy(): Promise<void> {
try {
if (this._destroyed) {
return;
}
this._destroyed = true;
// Z is managed by X therefore it should destroy it
await this.z.destroy();
} catch (e) {
this._destroyed = false;
throw e;
}
}
public async useYZ(): Promise<void> {
if (this._destroyed) {
throw new Error('Already destroyed!');
}
await this.y.doSomething();
await this.z.doSomething();
}
}
class Y {
protected _destroyed: boolean = false;
public static async createY() {
const y = new Y;
return y;
}
protected constructor() {
}
public async destroy(): Promise<void> {
try {
if (this._destroyed) {
return;
}
this._destroyed = true;
} catch (e) {
this._destroyed = false;
throw e;
}
}
public async doSomething(): Promise<void> {
if (this._destroyed) {
throw new Error('Y is destroyed');
}
console.log('Something from Y');
}
}
class Z {
protected _destroyed: boolean = false;
public static async createZ(): Promise<Z> {
const z = new Z;
return z;
}
protected constructor() {
}
public async destroy(): Promise<void> {
try {
if (this._destroyed) {
return;
}
this._destroyed = true;
} catch (e) {
this._destroyed = false;
throw e;
}
}
public async doSomething(): Promise<void> {
if (this._destroyed) {
throw new Error('Z is destroyed');
}
console.log('Something from Z');
}
}
async function main() {
const y = await Y.createY();
const x = await X.createX({
y
});
await x.useYZ();
}
/**
* Use this when object lifetime outlives object "readiness"
* This means there is some use of the object when the object isn't running
*/
class X {
protected _running: boolean = false;
public constructor() {
}
get running(): boolean {
return this._running;
}
public async start(): Promise<void> {
try {
if (this._running) {
return;
}
this._running = true;
} catch (e) {
this._running = false;
throw e;
}
}
public async stop() {
try {
if (!this._running) {
return;
}
this._running = false;
} catch (e) {
this._running = true;
throw e;
}
}
public async doSomething() {
if (!this._running) {
throw new Error('X is not running');
}
console.log('X did something');
}
}
class Y extends X {
public constructor() {
super();
}
public async start() {
await super.start();
}
public async stop() {
await super.stop();
}
}
async function main() {
const x = new X;
await x.start();
await x.stop();
await x.start();
await x.stop();
}
/**
* X is a start & stop pattern
* This uses composition pattern, not inheritance
*/
class X {
/**
* Public to indicate non-encapsulation of Y
*/
public y: Y;
/**
* Protected to indicate Z it is encapsulated and managed by X
*/
protected z: Z;
protected _running: boolean = false;
/**
* Y is a dependency but not encapsulated
* Z is an optional and overridable encapsulated dependency
*/
public constructor({
y,
z = new Z
}: {
y: Y,
z?: Z
}) {
this.y = y;
this.z = z;
}
get running(): boolean {
return this._running;
}
/**
* It is assumed that Y is already started
* This will start Z because Z is encapsulated
*/
public async start(): Promise<void> {
try {
if (this._running) {
return;
}
this._running = true;
await this.z.start();
} catch (e) {
this._running = false;
throw e;
}
}
public async stop() {
try {
if (!this._running) {
return;
}
this._running = false;
await this.z.stop();
} catch (e) {
this._running = true;
throw e;
}
}
public async useYZ() {
if (!this._running) {
throw new Error('X is not running');
}
await this.y.doSomething();
await this.z.doSomething();
}
}
class Y {
protected _running: boolean = false;
public constructor() {
}
get running(): boolean {
return this._running;
}
public async start(): Promise<void> {
try {
if (this._running) {
return;
}
this._running = true;
} catch (e) {
this._running = false;
throw e;
}
}
public async stop() {
try {
if (!this._running) {
return;
}
this._running = false;
} catch (e) {
this._running = true;
throw e;
}
}
public async doSomething() {
if (!this._running) {
throw new Error('Y is not running');
}
console.log('Y did something');
}
}
class Z {
protected _running: boolean = false;
public constructor() {
}
get running(): boolean {
return this._running;
}
public async start(): Promise<void> {
try {
if (this._running) {
return;
}
this._running = true;
} catch (e) {
this._running = false;
throw e;
}
}
public async stop() {
try {
if (!this._running) {
return;
}
this._running = false;
} catch (e) {
this._running = true;
throw e;
}
}
public async doSomething() {
if (!this._running) {
throw new Error('Z is not running');
}
console.log('Z did something');
}
}
async function main() {
const y = new Y();
const x = new X({
y: y
});
// you can start y and x out of order
await y.start();
await x.start();
await x.useYZ();
}
/**
* Use this when you need to start and stop and explicitly destroy the object
* Once destroyed, you can create it again
* If you stop it, you can start it again
* Remember you can only destroy after stopping
*/
class X {
protected _running: boolean = false;
protected _destroyed: boolean = false;
public static async createX(): Promise<X> {
const x = new X;
await x.start();
return x;
}
protected constructor() {
}
get running() {
return this._running;
}
get destroyed() {
return this._destroyed;
}
public async start(): Promise<void> {
try {
if (this._running) {
return;
}
if (this._destroyed) {
throw new Error('X is destroyed');
}
this._running = true;
} catch (e) {
this._running = false;
throw e;
}
}
public async stop(): Promise<void> {
try {
if (!this._running) {
return;
}
if (this._destroyed) {
throw new Error('X is destroyed');
}
this._running = false;
} catch (e) {
this._running = true;
throw e;
}
}
public async destroy(): Promise<void> {
try {
if (this._destroyed) {
return;
}
if (this._running) {
throw new Error('X is running');
}
this._destroyed = true;
} catch (e) {
this._destroyed = false;
throw e;
}
}
public async doSomething(): Promise<void> {
if (!this._running) {
throw new Error('X is not running');
}
console.log('X did something');
}
}
async function main() {
const x = await X.createX(); // x is started on creation
await x.doSomething();
await x.start(); // this is a noop
await x.doSomething();
await x.stop(); // stops x
await x.start(); // restarts x
await x.doSomething();
await x.stop(); // stops x
await x.destroy(); // destroy x only after stopping
}
@CMCDragonkai
Copy link
Author

Note that static methods are subject to the subtyping polymorphic constraint. Therefore we cannot use the same create method name in subclasses as we may need to vary our asynchronous initialisation parameters and return type. We can deal with this by suffixing our createX where X is the name of the class being created.

Ideally in the future JavasScript has some sort of asynchronous constructor provided as constructors can currently be overloaded and are not subject to subtyping requirement.

It is also possible to put the asynchronous creation method outside of the class entirely. But that makes it more difficult to bundle things together syntactically.

@CMCDragonkai
Copy link
Author

Other ways are listed here: https://2ality.com/2019/11/creating-class-instances.html

There is a typescript unfriendly way with using promises in the constructor itself. You have to tell TS to ignore the fact that the constructor is returning a promise.

Furthermore extending the class becomes complicated too.

@CMCDragonkai
Copy link
Author

@CMCDragonkai
Copy link
Author

  1. @matrixai/workers uses Async Create & Destroy - there's no use for a non-running WorkerManager
  2. @matrixai/db uses Async Create & Destroy & Start & Stop - you may wish to open and close the DB, but also separately destroy the DB

@CMCDragonkai
Copy link
Author

Because we don't use async-mutex in start, stop or otherwise. This means these methods including destroy are "non-blocking" in concurrent scenarios.

I believe this is better than mandating the use of locks here, since it is possible for the programmer to add in locks externally on top of the system later to manage concurrency. Instead starting out non-blocking is lighterweight and doesn't use any extra dependencies here.

@CMCDragonkai
Copy link
Author

CMCDragonkai commented Sep 29, 2021

Attempting to use decorators to generalize this. Here's an example for start & stop.

function StartStop<
  T extends {
    new (...args: any[]): {
      start(): Promise<void>;
      stop(): Promise<void>;
    };
  }
>(constructor: T) {
  return class extends constructor {
    protected _running: boolean = false;

    get running(): boolean {
      return this._running;
    }

    public async start(): Promise<void> {
      try {
        if (this._running) {
          return;
        }
        this._running = true;
        await super.start();
      } catch (e) {
        this._running = false;
        throw e;
      }
    }

    public async stop() {
      try {
        if (!this._running) {
          return;
        }
        this._running = false;
        await super.stop();
      } catch (e) {
        this._running = true;
        throw e;
      }
    }
  };
}

function Running(e: Error = new Error()) {
  return function runnable(
    target: Object,
    key: string | symbol,
    descriptor: PropertyDescriptor
  ) {
    const f = descriptor.value;
    if (typeof f !== 'function') {
      throw new Error;
    }
    descriptor.value = function (...args) {
      if (!this._running) {
        throw e;
      }
      return f.apply(this, args);
    }
    return descriptor;
  }
}

@StartStop
class X {
  protected y: Y;

  public constructor({ y = new Y }: { y?: Y } = {}) {
    this.y = y;
  }

  public async start (): Promise<void> {
    await this.y.start();
    console.log('X started');
  }

  public async stop(): Promise<void> {
    console.log('X stopped');
  }

  @Running()
  public async doSomething(): Promise<void> {
    console.log('X did something');
  }
}

@StartStop
class Y {
  public constructor() {
  }

  public async start () {
    console.log('Y started');
  }

  public async stop () {
    console.log('Y stopped');
  }
}

async function main () {
  const o = new X();
  console.log(o);
  // @ts-ignore
  console.log('X running', o.running);
  // @ts-ignore
  await o.start();
  // @ts-ignore
  console.log('X running', o.running);
  await o.doSomething();
  // @ts-ignore
  await o.stop();
  // @ts-ignore
  console.log('X running', o.running);
}

main();

Notice how the o doesn't get the type of the decoration. There are apparently 2 methods to solve this.

  1. Change the way we construct X, to const X = StartStop(class { ... });.
  2. Force type cast

The relevant issue for this problem is: microsoft/TypeScript#4881. It's still under discussion how to solve this.

@CMCDragonkai
Copy link
Author

CMCDragonkai commented Sep 29, 2021

Found a solution, now it works:

interface StartStop {
  get running(): boolean;
  start(...args: Array<any>): Promise<void>;
  stop(...args: Array<any>): Promise<void>;
}

function StartStop<
  T extends {
    new (...args: any[]): {
      start?(...args: Array<any>): Promise<void>;
      stop?(...args: Array<any>): Promise<void>;
    };
  }
>(constructor: T) {
  return class extends constructor {
    protected _running: boolean = false;

    get running(): boolean {
      return this._running;
    }

    public async start(...args: Array<any>): Promise<void> {
      try {
        if (this._running) {
          return;
        }
        this._running = true;
        if (typeof super['start'] === 'function') {
          await super.start(...args);
        }
      } catch (e) {
        this._running = false;
        throw e;
      }
    }

    public async stop(...args: Array<any>) {
      try {
        if (!this._running) {
          return;
        }
        this._running = false;
        if (typeof super['stop'] === 'function') {
          await super.stop(...args);
        }
      } catch (e) {
        this._running = true;
        throw e;
      }
    }
  };
}

function Running(e: Error = new Error()) {
  return function runnable(
    target: Object,
    key: string | symbol,
    descriptor: PropertyDescriptor
  ) {
    const f = descriptor.value;
    if (typeof f !== 'function') {
      throw new Error;
    }
    descriptor.value = function (...args) {
      if (!this._running) {
        throw e;
      }
      return f.apply(this, args);
    }
    return descriptor;
  }
}

interface X extends StartStop {};
@StartStop
class X {
  protected y: Y;

  public constructor({ y = new Y }: { y?: Y } = {}) {
    this.y = y;
  }

  public async start(p: number): Promise<void> {
    await this.y.start(1);
    console.log('X started', p);
  }

  public async stop(): Promise<void> {
    await this.y.stop();
    console.log('X stopped');
  }

  @Running()
  public async doSomething(): Promise<void> {
    console.log('X did something');
    await this.y.doSomething();
  }
}


interface Y extends StartStop {};
@StartStop
class Y {
  public constructor() {
  }

  public async start (p: number) {
    console.log('Y started', p);
  }

  public async stop () {
    console.log('Y stopped');
  }

  @Running(new Error('CustomError'))
  public async doSomething(): Promise<void> {
    console.log('Y did something');
  }
}

async function main () {
  const o = new X();
  console.log(o);
  console.log('X running', o.running);
  await o.start(1);
  console.log('X running', o.running);
  await o.doSomething();
  await o.stop();
  console.log('X running', o.running);
}

main();

@CMCDragonkai
Copy link
Author

It also works for create & destroy.

interface CreateDestroy {
  get destroyed(): boolean;
  destroy(...args: Array<any>): Promise<void>;
}

function CreateDestroy<
  T extends {
    new (...args: any[]): {
      destroy?(...args: Array<any>): Promise<void>;
    };
  }
>(constructor: T) {
  return class CreateDestroy extends constructor {
    protected _destroyed: boolean = false;

    get destroyed(): boolean {
      return this._destroyed;
    }

    public async destroy(...args: Array<any>): Promise<void> {
      try {
        if (this._destroyed) {
          return;
        }
        this._destroyed = true;
        if (typeof super['destroy'] === 'function') {
          await super.destroy(...args);
        }
      } catch (e) {
        this._destroyed = false;
        throw e;
      }
    }
  }
}

function Ready(e: Error = new Error()) {
  return (
    target: Object,
    key: string,
    descriptor: PropertyDescriptor
  ) => {
    const f = descriptor.value;
    if (typeof f !== 'function') {
      throw new TypeError(`${key} is not a function`);
    }
    descriptor.value = function (...args) {
      if (this._destroyed) {
        throw e;
      }
      return f.apply(this, args);
    }
    return descriptor;
  };
}

interface X extends CreateDestroy {};
@CreateDestroy
class X {
  protected y: Y;

  public static async createX(
    {
      y
    }: {
      y?: Y
    } = {}
  ) {
    y = y ?? await Y.createY();
    return new X({ y });
  }

  public constructor ({ y }: { y: Y }) {
    this.y = y;
  }

  public async destroy(): Promise<void> {
    await this.y.destroy();
    console.log('X destroyed');
  }

  @Ready()
  public async doSomething() {
    await this.y.doSomething();
    console.log('X did something');
  }
}

interface Y extends CreateDestroy {};
@CreateDestroy
class Y {
  public static async createY() {
    return new Y;
  }

  public constructor () {
  }

  public async destroy(): Promise<void> {
    console.log('Y destroyed');
  }

  @Ready(new Error('CustomError'))
  public async doSomething(): Promise<void> {
    console.log('Y did something');
  }

  @Ready()
  public async giveSomething(): Promise<number> {
    return 1;
  }
}

async function main() {
  const x = await X.createX();
  console.log(x.destroyed);
  await x.doSomething();
  await x.destroy();
  console.log(x.destroyed);
}

main();

The only problem is that the constructor has to be public for now. It's not a big deal to leave it as is.

@CMCDragonkai
Copy link
Author

So now the method decorator is always Ready(). One needs to pass a custom exception to be thrown when it isn't ready.

Finally for async create destroy and start stop.

@CMCDragonkai
Copy link
Author

This is now generalised to https://github.com/MatrixAI/js-async-init

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment