1

What I have

I have one class that executes in Its constructor only async code and other code that depends on async code result. According to some answers, to execute asynchronous code in class constructor I can follow one of two paths, run async code on init method or use an static async factory function.

Whichever path I take, I have to create some class properties with async operation result.

What I've tried:

1. Create static async factory function

class MyClass {
  private constructor() {
    // No async calls here
  }

  static async create (
    someParameter,
    anotherParameter,
  ) {
    const myClass = new Myclass();
    const foo = await something(someParameter);
    
    // Create some class properties with async operation result
    myClass.property = foo;

    return myClass;
  }
}

// with this I can create a class with
const myClass = await MyClass.create(x,y)

The problem with this aproach is, if I tried to access to class properties created with async result like so: myClass.property, TS compiler thrown an error TS2339: Property 'property' does not exist on type 'MyClass'. The other thing is ESLint thrown an error because of empty constructor: ESLint: Unexpected empty constructor..

2. Use init method

With this approach, I can access to this an asign properties to it:

class MyClass {
  property: SomeType;

  constructor() {
    // No async calls here
  }

  async init (
    someParameter,
    anotherParameter,
  ) {
    const foo = await something(someParameter);
    
    // Create some class properties with async operation result
    this.property = foo;

    return this;
  }
}

// with this I can create a class with
const currentClass = new MyClass();
const myClass = await currentClass.init(x, y); 

With this approach, I create a filed to avoid the problem TS2339: Property 'property' does not exist on type 'MyClass'. but some new problems are thrown: TS2564: Property 'property' has no initializer and is not definitely assigned in the constructor. and the constructor empty problem continues.

The problem

With the above, I don't know what its the correct way to run asynchronous code when initialising a class and create properties with that async result. if I leave the constructor empty I get the above mentioned errors and the same happens if I create fields without a constructor.

Update 1

The specific case I am dealing with is the following:

// Current class with async code in constructor
class Api {
  /* 
  * First Problem:
  *
  * I'm going to create some properties according to async result on crate/init method.
  * the problem with this is TS thrown the following error:
  * `S2564: Property 'auth' has no initializer and is not definitely 
  * assigned in the constructor.` This error is repeated for all fields. This
  * properties can't be assigned in constructor because of all of them are 
  * created on create/init method with async result values.
  */
  auth: AuthAPI;

  checkout: CheckoutAPI;

  cart: CartAPI;

  categories: CategoriesAPI;

  collections: CollectionsAPI;

  products: ProductsAPI;
  
  /* First Problem:: `ESLint: Unexpected empty constructor.`
   * as I don't have any property to be created synchronously, 
   * I end up with an empty constructor.
   */
  private constructor() {}

  // Static factory function to execute async code on class initialization, this can be replaced with `init method` to get the same result: execute asynchronous code when the class is initialised.
  static async create(
    // Configuration values 
    client: ApolloClient<any>,
    config: ConfigInput,
    onStateUpdate?: () => any,
  ): Promise<Api> {
    const api = new Api();
    const finalConfig = {
      ...defaultConfig,
      ...config,
    };
    const localStorageHandler = new LocalStorageHandler();
    const apolloClientManager = new ApolloClientManager(client);
    const jobsManager = await JobsManager.create(
      localStorageHandler,
      apolloClientManager,
    );

    // Async classes/methods to await 
    const saleorState = await SaleorState.create(
      finalConfig,
      localStorageHandler,
      apolloClientManager,
      jobsManager,
    );
    const localStorageManager = new LocalStorageManager(
      localStorageHandler,
      saleorState,
    );

    if (onStateUpdate) {
      saleorState.subscribeToNotifiedChanges(onStateUpdate);
    }

    // Create properties with async results
    api.auth = new AuthAPI(saleorState, jobsManager, finalConfig);
    api.checkout = new SaleorCheckoutAPI(saleorState, jobsManager);
    api.cart = new SaleorCartAPI(
      localStorageManager,
      apolloClientManager,
      saleorState,
      jobsManager,
    );
    api.categories = new CategoriesAPI(client);
    api.collections = new CollectionsAPI(client);
    api.products = new ProductsAPI(client);

    // Return the class with properties created
    return api;
  }
}

// Create the class and await to the result
const myApi = await Api.crate(
  apolloClient,
  config,
  onSaleorApiChange,
);

/*
 * Third problem: Here I access to some properties created with async code,
 * according to the comments in the class fields section, 
 * I'm faccing some problems with fields because if I don't have a constructor,
 * the properties give me the `TS error` mentioned above, and if I remove 
 * the fields and use the approach number 2 in which I use the `init method`
 * and access to `this`, when I am trying to access to class properties,
 * I get the error `TS2339: Property 'auth' does not exist on type 'Api'.`
 */
const auth = myApi.auth()

With the concrete example above, how do I solve:

  • first problem: TS problem with class fields
  • second problem: How to manage empty constructor?
  • third problem: access to class properties created with async values on create/init method after instantiating the class.
Cristian Flórez
  • 649
  • 1
  • 9
  • 17
  • `.create` returns a Promise, so do `= await MyClass.create` – CertainPerformance Mar 25 '21 at 04:54
  • @CertainPerformance sorry it was my fault, I forgot to put the await in the examples, even if you wait for the result of the method `create` this problems continues: `empty constructor` and `TS2339: Property 'property' does not exist on type 'MyClass'.` – Cristian Flórez Mar 25 '21 at 04:59

2 Answers2

1

Make the async call before constructing the class, and pass the result to the constructor so it can be put as a property synchronously. For example:

(async () => {
  class MyClass {
    property: number;
    constructor(foo: number) {
      this.property = foo;
    }
    static async create() {
      const foo = await Promise.resolve(10);
      const myClass = new MyClass(foo);
      return myClass;
    }
  }
  const myClass = await MyClass.create()
})();

With regards to your update, you can use the same technique. Don't call new Api until the async results are finished, and pass the results to the constructor, and the constructor can assign to the instance:

class Api {
    api: AuthAPI;
    // etc
    private constructor(api: AuthAPI, /* etc */) {
        this.api = api;
    }

    // Static factory function to execute async code on class initialization, this can be replaced with `init method` to get the same result: execute asynchronous code when the class is initialised.
    static async create(
        // Configuration values 
        client: ApolloClient<any>,
        config: ConfigInput,
        onStateUpdate?: () => any,
    ): Promise<Api> {
        const finalConfig = {
            ...defaultConfig,
            ...config,
        };
        const localStorageHandler = new LocalStorageHandler();
        const apolloClientManager = new ApolloClientManager(client);
        const jobsManager = await JobsManager.create(
            localStorageHandler,
            apolloClientManager,
        );

        // Async classes/methods to await 
        const saleorState = await SaleorState.create(
            finalConfig,
            localStorageHandler,
            apolloClientManager,
            jobsManager,
        );
        const localStorageManager = new LocalStorageManager(
            localStorageHandler,
            saleorState,
        );

        if (onStateUpdate) {
            saleorState.subscribeToNotifiedChanges(onStateUpdate);
        }

        return new Api(
            // Create properties with async results
            new AuthAPI(saleorState, jobsManager, finalConfig),
            new SaleorCheckoutAPI(saleorState, jobsManager),
            new SaleorCartAPI(
                localStorageManager,
                apolloClientManager,
                saleorState,
                jobsManager,
            ),
            new CategoriesAPI(client),
            new CollectionsAPI(client),
            new ProductsAPI(client),
        });
    }
}

For a more minimal example of this that compiles:

class Api {
    auth: number;
    private constructor(auth: number) {
        this.auth = auth;
    }
    static async create(): Promise<Api> {
        const auth = await Promise.resolve(5);
        return new Api(auth );
    }
}
(async () => {
    const myApi = await Api.create();
    const result = myApi.auth + 5;
})();

Demo

That said, for this particular situation, does the main class you're creating really not have any other methods? If not, then there's not much point to it - just use a plain object instead. You could change the bottom of create to

return {
    // Create properties with async results
    auth: new AuthAPI(saleorState, jobsManager, finalConfig),
    checkout: new SaleorCheckoutAPI(saleorState, jobsManager),
    cart: new SaleorCartAPI(
        localStorageManager,
        apolloClientManager,
        saleorState,
        jobsManager,
    ),
    categories: new CategoriesAPI(client),
    collections: new CollectionsAPI(client),
    products: new ProductsAPI(client),
};

and have create be just a plain function and not part of a class. That's what I'd strongly prefer if I was in your situation - it'll significantly cut down on the syntax noise of typing out the class and class properties/values and arguments (not to mention is just plain more appropriate).

CertainPerformance
  • 260,466
  • 31
  • 181
  • 209
  • I understand your point with the above approach, but what if I need to create properties with some values that are not necessarily passed as arguments to the constructor? are created inside class constructor like `this.myProperty = someAsyncResult` – Cristian Flórez Mar 25 '21 at 06:07
  • I don't think there's any good way to do that in TypeScript, since the instance will (rightfully) have be typed as something like `property: number | undefined`, so when you use the instance in the future, you'd have to check that the property is defined before using it every time. – CertainPerformance Mar 25 '21 at 06:09
  • The idea I have in my head is to hide some complexity when creating a class where only some configuration values are needed which can be passed as arguments to the constructor, perform the asynchronous operations and with Its result create properties which the final class will have. according to your previous answer the only way would be to create many arguments in the constructor? one for each property I want to create? the complexity of creating my class would be very high, imagine I want to create 6 or 8 properties, plus my initial configuration values. – Cristian Flórez Mar 25 '21 at 06:18
  • Seems separate from the async issue. You could have the constructor argument be a single object of the shape of the configuration, then assign it and the default configuration to the instance. `constructor(config: Config) { Object.assign(this, defaultConfig, config); }` or something like that – CertainPerformance Mar 25 '21 at 06:44
  • could you please update your answer to better understand your approach? – Cristian Flórez Mar 25 '21 at 07:08
  • I'm not entirely sure what the problem you're running into with multiple properties is - do they all require async calls, or just one, or what? Maybe give an example in your question? – CertainPerformance Mar 25 '21 at 12:56
  • You can check the `update 1` in my question, it is the concrete case I am facing, I recommend you to read the comments inside the class to have a better overview. – Cristian Flórez Mar 26 '21 at 00:10
  • See edit. Same technique works, just wait for all the async results ahead of time, then pass to constructor. Or, even better: remove the class entirely and use a plain object – CertainPerformance Mar 26 '21 at 03:22
0

Couple of ways to solve this.

1 - Embrace the fact that you truly may not have values for your properties, and define then all as optional (? Or type | undefined). With this approach your second approach would just work, but you would have to check to see if the value was defined downstream.

property?: SomeType

2 - Embrace the fact that the values are async, and set them to the promise of a value instead of a value itself. This would require you to await the property downstream, but would otherwise work. With this approach you could just use the constructor, and wouldn't need to do anything fancy.

property: Promise<SomeType>;

You could even get fancy and unwrap the promise after awaiting it, with the property being

property: Promise<SomeType> | SomeType;

But then you have to check to see if it is still a promise downstream, much like the undefined approach. For what it's worth, approach 1 you should just pre define the properties like you do with approach 2.

property: SomeType;

That will get rid of the error, but bring you back to the same error approach 2 has. You said this class definitely has value X, but it doesn't until later (after awaiting)

Tim
  • 2,756
  • 1
  • 11
  • 17
  • could you please update your answer with more complete examples where the approaches you mention can be seen? I don't quite understand certain things, like how you solve the empty constructor problem. – Cristian Flórez Mar 25 '21 at 07:33
  • If you don't have any non async fields, that could be initialized immediately, simply omit the constructor. It will get an empty constructor automatically – Tim Mar 25 '21 at 08:57
  • Removing the constructor solves one of my problems. But with the approaches you mention in your answer, some doubts arise: With first approach, making properties "optionals", whenever I want to use such a property I must first check that it is not `undefined` or `Promise` in the third approach, am I right? this becomes quite difficult to maintain if I have to always check the property value. I don't know what you mean by downstream? I would therefore like to see more detailed examples in order to clarify my doubts and to be able to know which path is the best to take. – Cristian Flórez Mar 26 '21 at 01:18