Search code examples
typescriptrxjsobservable

How to create an observable class in TypeScript using RXJS


I'm struggling to figure out how to make a model observable. I've read this and it's not what I want. I don't want individual properties to be observable, I want the entire class to be. Here's what I have so far:

export class ObservableModel extends Observable<any> {
    __internalSource = new BehaviorSubject(this);
    source = this.__internalSource.asObservable();
}

/**
 * Base entity class which all models should extend
 */
export class EntityModel extends ObservableModel {
   protected entityData: AnyEntity;
   protected entityState: {
      loading: boolean;
      loaded: boolean;
      reloading: boolean;
      error: boolean;
   } = {
      loading: false,
      loaded: false,
      reloading: false,
      error: false,
   };

   constructor(public type: string, public id?: string) {}

   from(value: AnyEntity) {
      const { type, id } = value;
      this.entityState = {
         loaded: true,
         loading: false,
         reloading: false,
         error: false,
      };

      this.entityData = value;
      this.__internalSource.next(this);
   }

   load() { ... }
   reload() { ... }
   private requestData() { ... }
}

export class CollectionModel extends EntityModel {
   @attr name: string;
}

export class ProductModel extends EntityModel {
   @attr name: string;
   @attr description: string;
   @hasOne('collection') collection: CollectionModel;
}

I have this working somewhat, but it breaks down. For instance, if I use the following in Angular

// component
export AppComponent {
   product = new ProductModel('product', '1');
}

// The following works...
<div *ngIf="product | async as prod">
   {{prod.loading}} works
   {{prod.reloading}} works
   {{prod.loaded}} works
   {{prod.error}} works
   {{prod.name}} works
   {{prod.id}} works
   {{prod.type}} works
</div>

// This doesn't work...
<div *ngIf="product | async as prod">
   <div *ngIf="prod.collection | async as collection">
      {{collection.loaded}} // TS throws an error that "Object is of type unknown"
   </div>
   <div *ngIf="prod.collection.loaded"> This works?
      {{ prod.collection.name }} works
   </div>
</div>

I don't understand why this is happening.


Solution

  • I'm not sure of the usability of it, but here's what I could come up with. The biggest issue was, as you can guess, the types. Since you use Observable<any> there was no way for typescript to do anything with it.

    export class ObservableModel<T> extends Observable<T> {
        __internalSource = new BehaviorSubject(this);
        source = this.__internalSource.asObservable();
    }
    

    And thus now, we have to propagate that change to the entity:

    /**
     * Base entity class which all models should extend
     */
    export class EntityModel<T> extends ObservableModel<T & EntityModel<T>> {
       protected entityData: any;
       protected entityState: {
          loading: boolean;
          loaded: boolean;
          reloading: boolean;
          error: boolean;
       } = {
          loading: false,
          loaded: false,
          reloading: false,
          error: false,
       };
    
       constructor(public type: string, public id?: string) {
         super();
       }
    
       from(value: any) {
          const { type, id } = value;
          this.entityState = {
             loaded: true,
             loading: false,
             reloading: false,
             error: false,
          };
    
          this.entityData = value;
          this.__internalSource.next(this);
       }
       
       getState() {
         return this.entityState;
       }
    }
    

    Note that I've also added a getter function for the state. That's because the way you accessed data in the template was wrong (or you were thinking about using something other than this as the values).

    Thus, now we fix how the template look like:

    // The following works...
    <div *ngIf="product | async as prod">
       {{prod.getState().loading}} works
       {{prod.getState().reloading}} works
       {{prod.getState().loaded}} works
       {{prod.getState().error}} works
       {{prod.name}} works
       {{prod.id}} works
       {{prod.type}} works
    </div>
    
    <div *ngIf="product | async as prod">
       <div *ngIf="prod.collection | async as collection">
          {{collection.getState().loaded}}
       </div>
       <div *ngIf="prod.collection | async as collection">
          <div *ngIf="collection.getState().loaded">
            {{ collection.name }} works
          </div>
       </div>
    </div>
    

    Everything works just about the same, except that:

    • We need to call getState() to have access to the "internal state"
    • We need to nest the .loaded condition within the async pipe if we want to make it look like it's a proper stream of data