angularabstractangular-httpclient

How can I get an object of a given type to be returned from HttpClient.get when requesting an abstract object?


I have an abstract class:

export abstract class DataEntry {
  ...
  public abstract clone(): DataEntry;
}

and an actual class extending it:

export class Record extends DataEntry {
  ...
  public clone(): Record {
    return new Record(...);
  }
}

I have a service to request data from a database with an HttpClient's get method.

export class DbFacadeService {
  public static BASEURL: string = 'http://localhost:3000';
  constructor(private _http: HttpClient) { }
  public readAll(): Observable<DataEntry[]> {
    return this._http.get<DataEntry[]>(`${DbFacadeService.BASEURL}`, { observe: 'response' })
                     .pipe(map((response: HttpResponse<DataEntry[]>) => response.body || []),
                           first());
  }
  ...
}

I periodically call this method from another service and store the results in a cache.

export class DataHandlerService {
  ...
  constructor(private _cache: Map<string, DataEntry>,
              private _dbFacade: DbFacadeService) {
    this._cache = new Map<string, DataEntry>([]);
    timer(0, 60000).subscribe(() => this.updateCache());
  }
  private updateCache(): void {
    this._dbFacade.readAll()
                  .subscribe((entries: DataEntry[]) => {
                               entries.forEach((entry: DataEntry) => this._cache.set(entry.id, entry);
                               ...
                             }
  }

  public readEntry(id: string): DataEntry | undefined {
    return this._cache.get(entryId);
  }
}

And then I'll call this method from my component, which displays the form for the user to edit:

export abstract class EditorComponent<T extends DataEntry> implements OnInit {
  ...
  public dataEntry: T | undefined;
  // can't create object of abstract DataEntry so I pass this responsibility to the actual class.
  protected abstract get _defaultValue(): T;
  public abstract entryForm: FormGroup;

  constructor(private _route: ActivatedRoute,
              private _router: Router,
              private _dataHandler: DataHandlerService) { }

  ngOnInit(): void {
    this._route.paramMap.subscribe((parameters: ParamMap) => this.init(parameters));
  }
  private init(parameters: ParamMap): void {
    const entryId: string | undefined = parameters.get('id') || undefined;
    if (!entryId) {
      // if no id in url, "create new object" was called, otherwise "edit existing object".
      this.dataEntry = this._defaultValue;
      return;
    }

    const entry: T | undefined = this._dataHandler.readEntry(entryId)?.clone() as T;
    if (!entry) {
      // navigate to an error page and display entry not found error
      this._router.navigate(['/error', '404.1']);
    }

    this.dataEntry = entry;
    this.entryForm.patchValue(this.dataEntry!);
  }
}

And then I extend from this abstract class in my actual component to display the specific form for a specific DataEntry subclass.

export class RecordEditorComponent extends EditorComponent<Record> {
  ...
  protected get _defaultValue(): Record { return new Record(...); }
  constructor(route: ActivatedRoute,
              router: Router,
              dataHandler: DataHandlerService,
              formBuilder: FormBuilder) {
    super(route, router, dataHandler);

    this.entryForm = formBuilder.group(...);
  }
}

To my surprise I had to learn that this._http.get<DataEntry[]>(...) does not actually return an observable with objects of the type given as type parameter but simply whatever was returned from the http call. Meaning, whatever I stored in the cache wasn't actually a DataEntry, much less a Record and thus did not contain the clone method. While it compiles (I guess the only reason for the type argument in the HttpClient only serves that purpose?) I receive a runtime exception as I cannot call undefined, i.e. the non-existing clone method.

I've tried to cast the response of the http call but this won't create an actual DataEntry object either:

.pipe(map((response: HttpResponse<DataEntry[]>) =>
            response.body?.map((entry: any) => entry as DataEntry) || [])

I've read that one must do the conversion manually in order to achieve this, i.e.:

.pipe(map((response: HttpResponse<DataEntry[]>) =>
            response.body?.map((entry: any) => new DataEntry(...)) || [])

However, I cannot create a new object of an abstract class. The DataHandlerService is oblivious as to what it's actually storing there, just that the objects are derived from DataEntry, so I can't create anything there as well (to my knowledge). The only thing I can think of is create an actual object from the given data instead of calling the clone method, but I would like to have the objects of the proper type already stored in the cache. I was thinking about a static creation method in the abstract DataEntry class but I can't make a static method abstract. I also couldn't find out how to create a new object of a dynamically given type.

So, is there a way to get objects of the actual Record (or whatever else will be derived from DataEntry in the future) in my cache? I couldn't find a way to dynamically create an object of a given type, so do I have to accept that whatever is in the cache is just any and I have to create the objects every time I call the cache?


Solution

  • There are many ways you can easily hydrate your data when it is used, but it looks hard to be able to do it in your cache as from what we see, there is no way for your cache to know the type of entity it has received.

    You'll be able to do it using your abstract component, by defining an abstract property which should have the contructor to use. Exactly like you do with your default.

    export abstract class EditorComponent<T extends DataEntry> implements OnInit {
      ...
      public dataEntry: T | undefined;
      // can't create object of abstract DataEntry so I pass this responsibility to the actual class.
      protected abstract get _defaultValue(): T;
      protected abstract get _entityConstructor(): Constructor<T>;
      public abstract entryForm: FormGroup;
    
      constructor(private _route: ActivatedRoute,
                  private _router: Router,
                  private _dataHandler: DataHandlerService) { }
    
      ngOnInit(): void {
        this._route.paramMap.subscribe((parameters: ParamMap) => this.init(parameters));
      }
      private init(parameters: ParamMap): void {
        const entryId: string | undefined = parameters.get('id') || undefined;
        if (!entryId) {
          // if no id in url, "create new object" was called, otherwise "edit existing object".
          this.dataEntry = this._defaultValue;
          return;
        }
    
        const entry: T | undefined = new this._entityConstructor(this._dataHandler.readEntry(entryId)).clone() as T;
        if (!entry) {
          // navigate to an error page and display entry not found error
          this._router.navigate(['/error', '404.1']);
        }
    
        this.dataEntry = entry;
        this.entryForm.patchValue(this.dataEntry!);
      }
    }
    

    If you really wanted to have your hydrated entities inside your cache, you'd need to have some way to detect the class of each element, either by the value of a property which would be easiest or by the presence of exclusive properties.

    You could have some list of available Models:

    const models = {MyModel:MyModelClass};
    // In your caching mecanic
    entries.forEach((entry: DataEntry) => this._cache.set(entry.id, models [entry.type]?models [entry.type](entry):entry);
    

    The problem with having it in the cache is that it will still return DataEntry typed elements. You would have to hint it to the readEntry function.

    We have already tackled the problem on our side, but we had one service per model, instead of having a generic one. That way components did not need to care about anything, they just used the appropriate service. This would also allow you more fine-grained caching mechanics, instead of having a big pool. You'd have one for each model. Their caching mecanics could even be tweeked differently depending on their type, like some type of objects are certinaly created/updated less often.